@replayio-app-building/netlify-recorder 0.17.0 → 0.18.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 (4) hide show
  1. package/README.md +195 -119
  2. package/dist/index.d.ts +47 -182
  3. package/dist/index.js +136 -301
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @replayio-app-building/netlify-recorder
2
2
 
3
- Capture and replay Netlify function executions as [Replay](https://replay.io) recordings. This package intercepts outbound network calls and environment variable reads during handler execution, stores the captured data via the Netlify Recorder service, and can later reproduce the exact execution as a Replay recording for debugging and analysis.
3
+ Capture and replay Netlify function executions as [Replay](https://replay.io) recordings. This package intercepts outbound network calls and environment variable reads during handler execution, stores the captured data in your app's own database, and can later reproduce the exact execution as a Replay recording for debugging and analysis.
4
4
 
5
5
  ## Installation
6
6
 
@@ -10,7 +10,19 @@ npm install @replayio-app-building/netlify-recorder
10
10
 
11
11
  ## Setup
12
12
 
13
- ### 1. Set required environment variables
13
+ ### 1. Create the backend_requests table
14
+
15
+ The package stores captured request data directly in your app's database. Call `backendRequestsEnsureTable` once during schema initialization to create the `backend_requests` table:
16
+
17
+ ```typescript
18
+ import { backendRequestsEnsureTable } from "@replayio-app-building/netlify-recorder";
19
+
20
+ await backendRequestsEnsureTable(sql);
21
+ ```
22
+
23
+ This creates a table with columns for the serialized blob data, git metadata (commit SHA, branch, repository URL), handler path, recording status, and timestamps.
24
+
25
+ ### 2. Set required environment variables
14
26
 
15
27
  `finishRequest` needs to know which repository, branch, and commit your deployed code belongs to. Set these environment variables on your Netlify site:
16
28
 
@@ -19,9 +31,8 @@ npm install @replayio-app-building/netlify-recorder
19
31
  | `REPLAY_REPOSITORY_URL` | Your app's git repository URL (e.g. `https://github.com/org/repo.git`) | Set in your deploy script or Netlify site settings |
20
32
  | `COMMIT_SHA` | The git commit hash of the deployed code | Set in your deploy script via `git rev-parse HEAD` |
21
33
  | `BRANCH_NAME` | The git branch of the deployed code | Set in your deploy script via `git rev-parse --abbrev-ref HEAD` |
22
- | `NETLIFY_RECORDER_SECRET` | Secret string for access control — restricts who can view and act on your captured requests | Set in Netlify site environment variables or via `set-branch-secret` |
23
34
 
24
- The first three are **required** — `finishRequest` will throw an error if any are missing. `NETLIFY_RECORDER_SECRET` is strongly recommended to prevent other apps from accessing your captured request data. Your deploy script should resolve the git values and set them on the Netlify site before deploying. Example:
35
+ All three are **required** — `finishRequest` will throw an error if any are missing. Your deploy script should resolve the git values and set them on the Netlify site before deploying. Example:
25
36
 
26
37
  ```typescript
27
38
  // In your deploy script:
@@ -33,19 +44,20 @@ const repositoryUrl = execSync("git remote get-url origin", { encoding: "utf-8"
33
44
  // Set these on your Netlify site via the Netlify API or CLI
34
45
  ```
35
46
 
36
- ### 2. Wrap your Netlify function
47
+ ### 3. Wrap your Netlify function
37
48
 
38
- Use `createRecordingRequestHandler` with `remoteCallbacks()` to wrap your handler with automatic request capture. Set `secret` to restrict access to captured requests only API calls providing the same secret can view or act on them.
49
+ Use `createRecordingRequestHandler` with `databaseCallbacks(sql)` to wrap your handler with automatic request capture. The captured data is stored directly in the `backend_requests` table in your database.
39
50
 
40
51
  **v1 handler** (Netlify Functions v1 — `event` with `httpMethod`, `path`, etc.):
41
52
 
42
53
  ```typescript
43
54
  import {
44
55
  createRecordingRequestHandler,
45
- remoteCallbacks,
56
+ databaseCallbacks,
46
57
  } from "@replayio-app-building/netlify-recorder";
58
+ import { neon } from "@neondatabase/serverless";
47
59
 
48
- const RECORDER_URL = "https://netlify-recorder-bm4wmw.netlify.app";
60
+ const sql = neon(process.env.DATABASE_URL!);
49
61
 
50
62
  const handler = createRecordingRequestHandler(
51
63
  async (event) => {
@@ -58,9 +70,8 @@ const handler = createRecordingRequestHandler(
58
70
  };
59
71
  },
60
72
  {
61
- callbacks: remoteCallbacks(RECORDER_URL),
73
+ callbacks: databaseCallbacks(sql),
62
74
  handlerPath: "netlify/functions/my-handler",
63
- secret: process.env.NETLIFY_RECORDER_SECRET,
64
75
  }
65
76
  );
66
77
 
@@ -72,10 +83,11 @@ export { handler };
72
83
  ```typescript
73
84
  import {
74
85
  createRecordingRequestHandler,
75
- remoteCallbacks,
86
+ databaseCallbacks,
76
87
  } from "@replayio-app-building/netlify-recorder";
88
+ import { neon } from "@neondatabase/serverless";
77
89
 
78
- const RECORDER_URL = "https://netlify-recorder-bm4wmw.netlify.app";
90
+ const sql = neon(process.env.DATABASE_URL!);
79
91
 
80
92
  // The wrapper reads the body from a clone — you can still read req.json() etc.
81
93
  export default createRecordingRequestHandler(
@@ -90,37 +102,57 @@ export default createRecordingRequestHandler(
90
102
  };
91
103
  },
92
104
  {
93
- callbacks: remoteCallbacks(RECORDER_URL),
105
+ callbacks: databaseCallbacks(sql),
94
106
  handlerPath: "netlify/functions/my-handler",
95
- secret: process.env.NETLIFY_RECORDER_SECRET,
96
107
  }
97
108
  );
98
109
  ```
99
110
 
100
- `createRecordingRequestHandler` automatically captures all outbound network calls and environment variable reads during your handler's execution, then sends the captured data to the Netlify Recorder service. The response includes an `X-Replay-Request-Id` header with the request ID managed by the service.
111
+ `createRecordingRequestHandler` automatically captures all outbound network calls and environment variable reads during your handler's execution, then stores the captured data in the `backend_requests` table. The response includes an `X-Replay-Request-Id` header with the ID of the stored request.
101
112
 
102
113
  > **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.
103
114
 
104
- ### 3. Create recordings
115
+ ### 4. Create recordings via the Netlify Recorder service
105
116
 
106
- When you want to turn a captured request into a Replay recording, POST to the Netlify Recorder service's `create-recording` endpoint with the request ID. If the request was created with a secret, you must include it:
117
+ When you want to turn a captured request into a Replay recording, POST to the Netlify Recorder service's `create-recording` endpoint. You need to provide a `blobDataUrl` a publicly accessible URL where the recording container can fetch the captured blob data.
118
+
119
+ If your app exposes the blob data via an endpoint (e.g. using `backendRequestsGetBlobData`), construct the URL and pass it:
107
120
 
108
121
  ```typescript
122
+ const RECORDER_URL = "https://netlify-recorder-bm4wmw.netlify.app";
123
+ const blobDataUrl = `https://your-app.netlify.app/api/get-backend-request-blob?requestId=${requestId}`;
124
+
109
125
  const response = await fetch(
110
126
  `${RECORDER_URL}/api/create-recording`,
111
127
  {
112
128
  method: "POST",
113
129
  headers: { "Content-Type": "application/json" },
114
130
  body: JSON.stringify({
115
- requestId,
116
- secret,
131
+ blobDataUrl,
132
+ handlerPath: "netlify/functions/my-handler",
133
+ commitSha: "abc123",
134
+ branchName: "main",
135
+ repositoryUrl: "https://github.com/org/repo.git", // optional
117
136
  webhookUrl: "https://your-app.netlify.app/api/on-recording-complete", // optional
118
137
  }),
119
138
  }
120
139
  );
140
+
141
+ const { requestId: serviceRequestId } = await response.json();
121
142
  ```
122
143
 
123
- The service looks up the stored blob data, dispatches the work to a recording container, and creates the recording.
144
+ The `create-recording` endpoint accepts:
145
+
146
+ | Parameter | Required | Description |
147
+ |---|---|---|
148
+ | `blobDataUrl` | Yes | URL where the recording container can fetch the captured blob JSON |
149
+ | `handlerPath` | Yes | Path to the handler file (e.g. `netlify/functions/my-handler`) |
150
+ | `commitSha` | Yes | Git commit SHA of the deployed code |
151
+ | `branchName` | Yes | Git branch of the deployed code |
152
+ | `repositoryUrl` | No | Git repository URL for the container to clone |
153
+ | `webhookUrl` | No | URL to POST the result to when the recording completes or fails |
154
+
155
+ The service dispatches the work to a recording container, which fetches the blob data from `blobDataUrl`, replays the handler execution under `replay-node`, and produces a Replay recording.
124
156
 
125
157
  If `webhookUrl` is provided, the service will POST the result to that URL when the recording completes (or fails):
126
158
 
@@ -134,97 +166,32 @@ On failure:
134
166
  { "status": "failed", "error": "Error message" }
135
167
  ```
136
168
 
137
- ### 4. Check recording status
169
+ ### 5. Manage stored requests
138
170
 
139
- You can poll the recording status at any time. If the request was created with a secret, you must include it:
171
+ Use the `backendRequests*` helpers to query and manage captured requests in your database:
140
172
 
141
173
  ```typescript
142
- const res = await fetch(
143
- `${RECORDER_URL}/api/get-request?requestId=${requestId}&secret=${secret}`
144
- );
145
- const { status, recordingId } = await res.json();
146
- // status: "captured" | "processing" | "recorded" | "failed"
147
- // recordingId: string | null
148
- ```
149
-
150
- Requests created with a secret return 403 if the secret is missing or incorrect.
151
-
152
- ### 5. Access control with secrets
153
-
154
- You can restrict access to captured requests by setting a `secret` string. When a secret is set, the request is only accessible via API calls that provide the same secret value. This lets each app isolate its requests from other apps sharing the same Netlify Recorder service.
155
-
156
- #### Setting a secret
157
-
158
- Pass `secret` in the options when wrapping your handler:
159
-
160
- ```typescript
161
- export default createRecordingRequestHandler(
162
- async (req) => {
163
- const result = await myBusinessLogic();
164
- return { statusCode: 200, body: JSON.stringify(result) };
165
- },
166
- {
167
- callbacks: remoteCallbacks(RECORDER_URL),
168
- handlerPath: "netlify/functions/my-handler",
169
- secret: process.env.NETLIFY_RECORDER_SECRET,
170
- }
171
- );
172
- ```
173
-
174
- #### Listing requests by secret
175
-
176
- Use the `requests` endpoint with a `secret` parameter to retrieve all requests associated with your secret, with optional filtering by status, handler path, and time range:
177
-
178
- ```typescript
179
- const res = await fetch(
180
- `${RECORDER_URL}/api/requests?secret=${secret}&status=recorded&handlerPath=netlify/functions/my-handler&after=2025-01-01T00:00:00Z&before=2025-12-31T23:59:59Z`
181
- );
182
- const { rows, total, page, limit } = await res.json();
183
- ```
184
-
185
- Query parameters:
186
-
187
- | Parameter | Description |
188
- |---|---|
189
- | `secret` | **(required)** The secret string used when creating the requests |
190
- | `id` | Search by request ID prefix |
191
- | `status` | Filter by status: `captured`, `queued`, `processing`, `recorded`, `failed`, `all` |
192
- | `handlerPath` | Filter by handler path (exact match) |
193
- | `after` | Only include requests created at or after this ISO timestamp |
194
- | `before` | Only include requests created at or before this ISO timestamp |
195
- | `page` | Page number (default 1) |
196
- | `limit` | Page size (default 20, max 100) |
174
+ import {
175
+ backendRequestsGet,
176
+ backendRequestsList,
177
+ backendRequestsUpdateStatus,
178
+ } from "@replayio-app-building/netlify-recorder";
197
179
 
198
- Without a `secret` parameter, only requests created without a secret are returned.
180
+ // Get a single request by ID
181
+ const request = await backendRequestsGet(sql, requestId);
182
+ // request.status: "captured" | "queued" | "processing" | "recorded" | "failed"
183
+ // request.recording_id: string | null
199
184
 
200
- #### Checking request status with a secret
185
+ // List requests with optional filters
186
+ const requests = await backendRequestsList(sql, { status: "captured", limit: 20 });
201
187
 
202
- When a request was created with a secret, you must include the secret when polling its status:
188
+ // Update status after recording completes
189
+ await backendRequestsUpdateStatus(sql, requestId, "recorded", recordingId);
203
190
 
204
- ```typescript
205
- const res = await fetch(
206
- `${RECORDER_URL}/api/get-request?requestId=${requestId}&secret=${secret}`
207
- );
191
+ // Update status on failure
192
+ await backendRequestsUpdateStatus(sql, requestId, "failed", undefined, "Error message");
208
193
  ```
209
194
 
210
- Requests created without a secret remain accessible without one (backward compatible).
211
-
212
- #### Managing your secret
213
-
214
- Store your secret as a `NETLIFY_RECORDER_SECRET` environment variable. To keep it secure:
215
-
216
- 1. **Netlify site environment:** Add `NETLIFY_RECORDER_SECRET` in your Netlify site's environment variables (Site settings > Environment variables). This makes it available to all deployed functions.
217
-
218
- 2. **Branch-level secret** (for CI/development): If you're using the Netlify Recorder agent infrastructure, store the secret as a branch secret so deploy scripts and background agents can access it:
219
-
220
- ```bash
221
- set-branch-secret NETLIFY_RECORDER_SECRET "your-secret-value"
222
- ```
223
-
224
- 3. **Local development:** Add `NETLIFY_RECORDER_SECRET` to your `.env` file (make sure `.env` is in `.gitignore`).
225
-
226
- Use a strong random string (e.g. `openssl rand -base64 32`) and rotate it if compromised. All requests created under the old secret remain accessible only with the old secret value — there is no migration mechanism, so plan rotations during low-traffic windows.
227
-
228
195
  ---
229
196
 
230
197
  ## Audit Log Support
@@ -261,7 +228,7 @@ No special SQL wrapper is needed. Any Neon SQL query inside a handler wrapped wi
261
228
  ```typescript
262
229
  import {
263
230
  createRecordingRequestHandler,
264
- remoteCallbacks,
231
+ databaseCallbacks,
265
232
  } from "@replayio-app-building/netlify-recorder";
266
233
 
267
234
  export default createRecordingRequestHandler(
@@ -271,9 +238,8 @@ export default createRecordingRequestHandler(
271
238
  return { statusCode: 200, body: "OK" };
272
239
  },
273
240
  {
274
- callbacks: remoteCallbacks(RECORDER_URL),
241
+ callbacks: databaseCallbacks(sql),
275
242
  handlerPath: "netlify/functions/create-order",
276
- secret: process.env.NETLIFY_RECORDER_SECRET,
277
243
  }
278
244
  );
279
245
  ```
@@ -314,22 +280,21 @@ The network interceptor detects Neon SQL HTTP requests (which use `fetch` intern
314
280
 
315
281
  Wraps a Netlify handler function with automatic request recording. This is the recommended way to integrate — it handles `startRequest`/`finishRequest` and error cleanup internally.
316
282
 
317
- **Response timing:** When the Netlify Functions v2 `context` object is available (with `waitUntil`), the response is returned to the client **immediately** with a pre-generated `X-Replay-Request-Id` header. The blob upload and metadata storage continue in the background via `context.waitUntil()`, adding zero latency to the client response.
283
+ **Response timing:** When the Netlify Functions v2 `context` object is available (with `waitUntil`), the response is returned to the client **immediately** with a pre-generated `X-Replay-Request-Id` header. The data storage continues in the background via `context.waitUntil()`, adding zero latency to the client response.
318
284
 
319
285
  When `context.waitUntil` is not available (v1 handlers or missing context), the wrapper falls back to awaiting `finishRequest` before returning.
320
286
 
321
287
  **Parameters:**
322
288
  - `handler` — Your async handler function `(event, context?) => Promise<{ statusCode, headers?, body? }>`
323
- - `options.callbacks` — `remoteCallbacks(serviceUrl)` for sending captured data to the Netlify Recorder service
289
+ - `options.callbacks` — `databaseCallbacks(sql)` to store captured data in the `backend_requests` table
324
290
  - `options.handlerPath` — Path to the handler file (used for recording metadata)
325
291
  - `options.commitSha` — Override `COMMIT_SHA` env var
326
292
  - `options.branchName` — Override `BRANCH_NAME` env var
327
293
  - `options.repositoryUrl` — Override `REPLAY_REPOSITORY_URL` env var
328
- - `options.secret` — Optional secret string. When set, the stored request is only accessible via API calls that provide the same secret value.
329
294
 
330
295
  **Returns:** A wrapped handler function with the same signature.
331
296
 
332
- **Callbacks note:** When using the `waitUntil` flow, `storeRequestData` receives a `requestId` field in its data parameter. Callbacks should use this as the row ID so the stored record matches the ID already sent to the client.
297
+ **Callbacks note:** When using the `waitUntil` flow, `storeRequest` receives a `requestId` field in its data parameter. Callbacks should use this as the row ID so the stored record matches the ID already sent to the client.
333
298
 
334
299
  ### `startRequest(event): RequestContext`
335
300
 
@@ -346,31 +311,143 @@ For v2 Request inputs, the body is read from a **clone** — the original reques
346
311
 
347
312
  ### `finishRequest(requestContext, callbacks, response, options?): Promise<HandlerResponse>`
348
313
 
349
- Lower-level API. Finalizes the request capture. Restores original `fetch` and `process.env`, serializes the captured data, uploads it via the callbacks, and returns the response with `X-Replay-Request-Id` header set.
314
+ Lower-level API. Finalizes the request capture. Restores original `fetch` and `process.env`, serializes the captured data, stores it via the callback, and returns the response with `X-Replay-Request-Id` header set.
350
315
 
351
316
  **Important:** You must send the returned response to the client — it contains the `X-Replay-Request-Id` header.
352
317
 
353
- Logs a `console.warn` when the total duration exceeds 2 seconds or when individual callback steps (upload, store) exceed 1 second, to help diagnose slow operations.
318
+ Logs a `console.warn` when the total duration exceeds 2 seconds to help diagnose slow operations.
354
319
 
355
320
  **Requires** the following environment variables (or equivalent options overrides): `COMMIT_SHA`, `BRANCH_NAME`, `REPLAY_REPOSITORY_URL`. Throws if any are missing.
356
321
 
357
322
  **Parameters:**
358
323
  - `requestContext` — The context returned by `startRequest`
359
- - `callbacks` — `remoteCallbacks(serviceUrl)` for sending captured data to the Netlify Recorder service
324
+ - `callbacks` — `databaseCallbacks(sql)` or a custom `{ storeRequest }` callback
360
325
  - `response` — The handler's response object (`{ statusCode, headers?, body? }`)
361
326
  - `options.handlerPath` — Path to the handler file (used for recording metadata)
362
327
  - `options.commitSha` — Override `COMMIT_SHA` env var
363
328
  - `options.branchName` — Override `BRANCH_NAME` env var
364
329
  - `options.repositoryUrl` — Override `REPLAY_REPOSITORY_URL` env var
365
330
  - `options.requestId` — Pre-generated request ID (used by `createRecordingRequestHandler` in the `waitUntil` flow)
366
- - `options.secret` — Optional secret string. When set, the stored request is only accessible via API calls that provide the same secret value.
367
331
 
368
- ### `remoteCallbacks(serviceUrl): FinishRequestCallbacks`
332
+ ### `databaseCallbacks(sql): FinishRequestCallbacks`
333
+
334
+ Creates a `FinishRequestCallbacks` object that stores captured request data directly in the `backend_requests` table. This is the standard way to provide callbacks to `createRecordingRequestHandler` or `finishRequest`.
335
+
336
+ **Parameters:**
337
+ - `sql` — A Neon SQL tagged-template function
338
+
339
+ **Returns:** An object with a `storeRequest` method that inserts rows into `backend_requests`.
340
+
341
+ ### `backendRequestsEnsureTable(sql): Promise<void>`
342
+
343
+ Creates the `backend_requests` table and its indexes. Call once during schema initialization.
344
+
345
+ The table schema:
346
+
347
+ | Column | Type | Description |
348
+ |---|---|---|
349
+ | `id` | UUID (PK) | Auto-generated request ID |
350
+ | `blob_data` | TEXT | Serialized JSON of captured request data |
351
+ | `handler_path` | TEXT | Path to the handler file |
352
+ | `commit_sha` | TEXT | Git commit SHA |
353
+ | `branch_name` | TEXT | Git branch name (default: `'main'`) |
354
+ | `repository_url` | TEXT | Git repository URL (nullable) |
355
+ | `status` | TEXT | `'captured'`, `'queued'`, `'processing'`, `'recorded'`, or `'failed'` |
356
+ | `recording_id` | TEXT | Replay recording ID (set when status is `'recorded'`) |
357
+ | `error_message` | TEXT | Error details (set when status is `'failed'`) |
358
+ | `created_at` | TIMESTAMPTZ | Row creation time |
359
+ | `updated_at` | TIMESTAMPTZ | Last update time |
360
+
361
+ **Parameters:**
362
+ - `sql` — A Neon SQL tagged-template function
363
+
364
+ ### `backendRequestsInsert(sql, data): Promise<string>`
365
+
366
+ Inserts a new row into `backend_requests` and returns the generated (or provided) ID.
367
+
368
+ **Parameters:**
369
+ - `sql` — A Neon SQL tagged-template function
370
+ - `data.blobData` — Serialized blob JSON string
371
+ - `data.handlerPath` — Handler file path
372
+ - `data.commitSha` — Git commit SHA
373
+ - `data.branchName` — Git branch name
374
+ - `data.repositoryUrl` — Git repository URL (optional)
375
+ - `data.id` — Pre-generated UUID (optional; auto-generated if omitted)
376
+
377
+ ### `backendRequestsGet(sql, id): Promise<BackendRequest | null>`
378
+
379
+ Retrieves a single request by ID, or `null` if not found.
380
+
381
+ **Parameters:**
382
+ - `sql` — A Neon SQL tagged-template function
383
+ - `id` — The request UUID
384
+
385
+ ### `backendRequestsGetBlobData(sql, id): Promise<string | null>`
369
386
 
370
- Creates callbacks for `finishRequest` that send captured data to the Netlify Recorder service. The service handles blob storage and request tracking.
387
+ Retrieves only the `blob_data` column for a request, or `null` if not found. Use this to serve blob data to the recording container without fetching the full row.
371
388
 
372
389
  **Parameters:**
373
- - `serviceUrl` — Base URL of the Netlify Recorder service (e.g. `"https://netlify-recorder-bm4wmw.netlify.app"`)
390
+ - `sql` — A Neon SQL tagged-template function
391
+ - `id` — The request UUID
392
+
393
+ ### `backendRequestsList(sql, filters?): Promise<BackendRequest[]>`
394
+
395
+ Lists requests ordered by `created_at` DESC, with optional filters.
396
+
397
+ **Parameters:**
398
+ - `sql` — A Neon SQL tagged-template function
399
+ - `filters.status` — Filter by status (e.g. `"captured"`, `"recorded"`)
400
+ - `filters.limit` — Maximum rows to return (default: 50)
401
+
402
+ ### `backendRequestsUpdateStatus(sql, id, status, recordingId?, errorMessage?): Promise<void>`
403
+
404
+ Updates the status of a request. Optionally sets `recording_id` (on success) or `error_message` (on failure).
405
+
406
+ **Parameters:**
407
+ - `sql` — A Neon SQL tagged-template function
408
+ - `id` — The request UUID
409
+ - `status` — New status string
410
+ - `recordingId` — Replay recording ID (optional, for `"recorded"` status)
411
+ - `errorMessage` — Error details (optional, for `"failed"` status)
412
+
413
+ ### `createRequestRecording(blobUrlOrData, handlerPath, requestInfo): Promise<RecordingResult>`
414
+
415
+ 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.
416
+
417
+ Returns a `RecordingResult` with mismatch detection:
418
+
419
+ | Field | Type | Description |
420
+ |---|---|---|
421
+ | `responseMismatch` | boolean | Whether the replay response differs from the captured response |
422
+ | `mismatchDetails` | string? | Description of the mismatch |
423
+ | `replayResponse` | HandlerResponse? | The response produced during replay |
424
+ | `capturedResponse` | HandlerResponse? | The original captured response |
425
+ | `unconsumedNetworkCalls` | boolean | Whether some recorded network calls were not replayed |
426
+ | `unconsumedNetworkDetails` | string? | Details about unconsumed calls |
427
+
428
+ **Parameters:**
429
+ - `blobUrlOrData` — URL to the captured data blob, or pre-parsed `BlobData` object
430
+ - `handlerPath` — Path to the handler module to execute
431
+ - `requestInfo` — The original request info to replay
432
+
433
+ ### `getCurrentRequestId(): string | null`
434
+
435
+ Returns the request ID for the currently executing handler, or `null` if no handler is active. Useful for correlating logs or database operations with the current request.
436
+
437
+ ### `redactBlobData(data): BlobData`
438
+
439
+ Redacts sensitive environment variable values from captured blob data before storage. Applied automatically by `finishRequest` — you only need to call this directly if using the lower-level APIs.
440
+
441
+ Redaction rules:
442
+ - Allow-listed keys (standard system/runtime variables like `NODE_ENV`, `PATH`, `COMMIT_SHA`) are never redacted
443
+ - Values that are `undefined` or 8 characters or shorter are kept as-is
444
+ - All other values are replaced with `*` repeated to the same length
445
+ - Redacted values are also scrubbed from all other string fields in the blob (request headers, network call bodies, etc.) to prevent leakage through embedded values
446
+
447
+ **Parameters:**
448
+ - `data` — A `BlobData` object
449
+
450
+ **Returns:** A new `BlobData` object with sensitive values masked.
374
451
 
375
452
  ### `databaseAuditEnsureLogTable(sql): Promise<void>`
376
453
 
@@ -403,10 +480,9 @@ These must be set on your Netlify site. Your deploy script should resolve them f
403
480
  | `COMMIT_SHA` | Git commit hash of the deployed code | `git rev-parse HEAD` |
404
481
  | `BRANCH_NAME` | Git branch of the deployed code | `git rev-parse --abbrev-ref HEAD` |
405
482
  | `REPLAY_REPOSITORY_URL` | Git repository URL (no embedded credentials) | `git remote get-url origin` (strip tokens) |
406
- | `NETLIFY_RECORDER_SECRET` | Secret for access control (strongly recommended) | `openssl rand -base64 32` — store in Netlify site env vars |
407
483
 
408
484
  ## How It Works
409
485
 
410
- 1. **Capture phase** (`createRecordingRequestHandler` or `startRequest` / `finishRequest`): When a Netlify function handles a request, the recording layer patches `globalThis.fetch` and `process.env` with Proxies that record every outbound network call and environment variable read. When the handler completes, the originals are restored, the captured data is serialized to JSON, and sent to the Netlify Recorder service via `remoteCallbacks`.
486
+ 1. **Capture phase** (`createRecordingRequestHandler` or `startRequest` / `finishRequest`): When a Netlify function handles a request, the recording layer patches `globalThis.fetch` and `process.env` with Proxies that record every outbound network call and environment variable read. Sensitive environment variable values are automatically redacted. When the handler completes, the originals are restored, the captured data is serialized to JSON, and stored in the `backend_requests` table in your database via `databaseCallbacks`.
411
487
 
412
- 2. **Recording phase**: The Netlify Recorder service dispatches the captured data to a recording container. Inside the container, the captured blob is downloaded, replay-mode interceptors return the pre-recorded responses instead of making real calls, and the original handler is re-executed under `replay-node`. Since replay-node records all execution, this produces a Replay recording of the exact same handler execution.
488
+ 2. **Recording phase**: To create a Replay recording, POST to the Netlify Recorder service's `create-recording` endpoint with a `blobDataUrl` pointing to the captured data. The service dispatches the work to a recording container, which fetches the blob data from the URL, installs replay-mode interceptors that return pre-recorded responses instead of making real calls, and re-executes the handler under `replay-node`. Since `replay-node` records all execution, this produces a Replay recording of the exact same handler execution. The recording result includes mismatch detection — the service compares the replay response against the originally captured response to flag any divergence.
package/dist/index.d.ts CHANGED
@@ -93,66 +93,24 @@ interface BlobData {
93
93
  handlerResponse?: HandlerResponse$1;
94
94
  }
95
95
  interface FinishRequestCallbacks {
96
- /** Uploads serialized captured data and returns the blob URL. */
97
- uploadBlob: (data: string) => Promise<string>;
98
96
  /**
99
- * Stores request metadata in the database and returns the request ID.
97
+ * Stores the captured request data and returns the request ID.
100
98
  *
101
99
  * When `requestId` is provided (from `createRecordingRequestHandler`'s
102
100
  * `waitUntil` flow), the callback should use it as the row ID so the
103
- * client-facing header and the stored record match. When omitted, the
104
- * callback generates its own ID (backward-compatible).
101
+ * client-facing header and the stored record match. When omitted, the
102
+ * callback generates its own ID.
105
103
  */
106
- storeRequestData: (data: {
107
- blobUrl: string;
104
+ storeRequest: (data: {
105
+ blobData: string;
108
106
  commitSha: string;
109
107
  branchName: string;
110
108
  repositoryUrl: string;
111
109
  handlerPath: string;
112
110
  /** Pre-generated request ID. Use as the row ID when provided. */
113
111
  requestId?: string;
114
- /** Optional secret that restricts access to this request. */
115
- secret?: string;
116
112
  }) => Promise<string>;
117
113
  }
118
- /**
119
- * Infrastructure credentials required to start a recording container.
120
- *
121
- * The container is started on Fly.io via the `@replayio/app-building` package.
122
- * It requires Infisical credentials (for secrets management inside the
123
- * container) and a Fly.io token + app name.
124
- *
125
- * These must be set as environment variables on the Netlify site:
126
- * INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET,
127
- * INFISICAL_PROJECT_ID, INFISICAL_ENVIRONMENT,
128
- * FLY_API_TOKEN, FLY_APP_NAME
129
- */
130
- interface ContainerInfraConfig {
131
- infisicalClientId: string;
132
- infisicalClientSecret: string;
133
- infisicalProjectId: string;
134
- infisicalEnvironment: string;
135
- flyToken: string;
136
- flyApp: string;
137
- }
138
- interface EnsureRecordingOptions {
139
- repositoryUrl: string;
140
- /** Infrastructure credentials for starting the recording container. */
141
- infraConfig?: ContainerInfraConfig;
142
- /** Webhook URL the container can POST log entries to (optional). */
143
- webhookUrl?: string;
144
- /** Looks up request metadata by ID. */
145
- lookupRequest: (requestId: string) => Promise<{
146
- blobUrl: string;
147
- commitSha: string;
148
- branchName: string;
149
- handlerPath: string;
150
- }>;
151
- /** Updates the request status (and optionally recording ID) in the database. */
152
- updateStatus: (requestId: string, status: string, recordingId?: string) => Promise<void>;
153
- /** Optional callback for the caller to emit structured log entries. */
154
- onLog?: (level: "info" | "warn" | "error", message: string) => Promise<void>;
155
- }
156
114
 
157
115
  /**
158
116
  * Called at the beginning of a Netlify handler execution.
@@ -212,32 +170,16 @@ interface FinishRequestOptions {
212
170
  repositoryUrl?: string;
213
171
  /**
214
172
  * Pre-generated request ID. When provided, this ID is passed to the
215
- * `storeRequestData` callback so the stored row matches the ID already
173
+ * `storeRequest` callback so the stored row matches the ID already
216
174
  * sent to the client in the `X-Replay-Request-Id` header.
217
- *
218
- * Used by `createRecordingRequestHandler` in the `waitUntil` flow where
219
- * the response is returned before `finishRequest` runs.
220
175
  */
221
176
  requestId?: string;
222
- /**
223
- * Optional secret string. When set, the stored request is only
224
- * accessible via API calls that provide the same secret value.
225
- */
226
- secret?: string;
227
177
  }
228
178
  /**
229
179
  * Called at the end of the handler execution.
230
180
  * Restores original globals, serializes all captured data,
231
- * uploads it as a JSON blob via the provided callback,
232
- * stores the request metadata, and sets the X-Replay-Request-Id header.
233
- *
234
- * **Important:** The returned response includes the `X-Replay-Request-Id`
235
- * header. You must send the returned response to the client — not the
236
- * original response object you passed in.
237
- *
238
- * Logs a warning to `console.warn` when the total finishRequest time or
239
- * individual callback steps exceed their thresholds, to help diagnose
240
- * slow blob uploads or database writes.
181
+ * stores the request via the provided callback, and sets the
182
+ * X-Replay-Request-Id header.
241
183
  */
242
184
  declare function finishRequest(requestContext: RequestContext, callbacks: FinishRequestCallbacks, response: FullHandlerResponse, options?: FinishRequestOptions): Promise<FullHandlerResponse>;
243
185
 
@@ -254,80 +196,15 @@ interface CreateRecordingRequestHandlerOptions extends FinishRequestOptions {
254
196
  *
255
197
  * Automatically calls `startRequest` before the handler and `finishRequest`
256
198
  * after, capturing all outbound network calls and environment variable reads.
257
- * On error, interceptors are cleaned up and the error is re-thrown.
199
+ * The captured data is stored via the provided callbacks.
258
200
  *
259
201
  * **Response timing:** When the Netlify Functions v2 `context` object is
260
202
  * available (with `waitUntil`), the response is returned to the client
261
203
  * **immediately** with a pre-generated `X-Replay-Request-Id` header. The
262
- * blob upload and metadata storage continue in the background via
263
- * `context.waitUntil()`. This avoids adding latency to the client response.
264
- *
265
- * When `context.waitUntil` is not available (v1 handlers or missing context),
266
- * the wrapper falls back to awaiting `finishRequest` before returning.
267
- *
268
- * For v2 handlers the request body is read from a clone internally — your
269
- * handler still receives the original request with an unconsumed body.
270
- *
271
- * @example v1 handler (NetlifyEvent)
272
- * ```typescript
273
- * import { createRecordingRequestHandler, remoteCallbacks } from "@replayio-app-building/netlify-recorder";
274
- *
275
- * const handler = createRecordingRequestHandler(
276
- * async (event) => {
277
- * const result = await myBusinessLogic(event.body);
278
- * return {
279
- * statusCode: 200,
280
- * headers: { "Content-Type": "application/json" },
281
- * body: JSON.stringify(result),
282
- * };
283
- * },
284
- * {
285
- * callbacks: remoteCallbacks("https://netlify-recorder-bm4wmw.netlify.app"),
286
- * handlerPath: "netlify/functions/my-handler",
287
- * }
288
- * );
289
- *
290
- * export { handler };
291
- * ```
292
- *
293
- * @example v2 handler (Web API Request)
294
- * ```typescript
295
- * import { createRecordingRequestHandler, remoteCallbacks } from "@replayio-app-building/netlify-recorder";
296
- *
297
- * export default createRecordingRequestHandler(
298
- * async (req) => {
299
- * // Body is still available — startRequest reads from a clone
300
- * const body = await (req as Request).json();
301
- * const result = await myBusinessLogic(body);
302
- * return {
303
- * statusCode: 200,
304
- * headers: { "Content-Type": "application/json" },
305
- * body: JSON.stringify(result),
306
- * };
307
- * },
308
- * {
309
- * callbacks: remoteCallbacks("https://netlify-recorder-bm4wmw.netlify.app"),
310
- * handlerPath: "netlify/functions/my-handler",
311
- * }
312
- * );
313
- * ```
204
+ * data storage continues in the background via `context.waitUntil()`.
314
205
  */
315
206
  declare function createRecordingRequestHandler(handler: (event: NetlifyEvent | NetlifyV2Request, context?: unknown) => Promise<HandlerResponse>, options: CreateRecordingRequestHandlerOptions): (event: NetlifyEvent | NetlifyV2Request, context?: unknown) => Promise<HandlerResponse>;
316
207
 
317
- /**
318
- * Creates `FinishRequestCallbacks` that send captured data to a remote
319
- * Netlify Recorder service. This removes the need for the consuming app
320
- * to set up its own blob storage or database tables.
321
- *
322
- * The callbacks upload blob data and store request metadata via the
323
- * service's `/api/store-request` endpoint, which handles UploadThing
324
- * storage and database insertion.
325
- *
326
- * @param serviceUrl - Base URL of the Netlify Recorder service
327
- * (e.g. "https://netlify-recorder-bm4wmw.netlify.app")
328
- */
329
- declare function remoteCallbacks(serviceUrl: string): FinishRequestCallbacks;
330
-
331
208
  /**
332
209
  * Redacts sensitive environment variable values from blob data.
333
210
  *
@@ -348,28 +225,6 @@ declare function remoteCallbacks(serviceUrl: string): FinishRequestCallbacks;
348
225
  */
349
226
  declare function redactBlobData(blobData: BlobData): BlobData;
350
227
 
351
- /**
352
- * Called by a background function to convert a request ID into a Replay recording ID.
353
- *
354
- * The function:
355
- * 1. Looks up request metadata (blob URL, commit, handler path).
356
- * 2. Delegates to `spawnRecordingContainer` which starts a detached Fly.io
357
- * container, runs the recording script under replay-node, and uploads
358
- * the resulting recording.
359
- * 3. Updates the request status with the recording ID.
360
- *
361
- * **Required infrastructure:** Infisical credentials and a Fly.io token/app.
362
- * See `ContainerInfraConfig` in types.ts for details. When these are not
363
- * configured the function fails with an actionable error message listing
364
- * the missing environment variables.
365
- */
366
- declare function ensureRequestRecording(requestId: string, options: EnsureRecordingOptions): Promise<string>;
367
- /**
368
- * Reads infrastructure config from environment variables.
369
- * Returns undefined if any required variable is missing.
370
- */
371
- declare function readInfraConfigFromEnv(): ContainerInfraConfig | undefined;
372
-
373
228
  interface RecordingResult {
374
229
  /** Whether a response mismatch was detected between capture and replay. */
375
230
  responseMismatch: boolean;
@@ -394,39 +249,49 @@ interface RecordingResult {
394
249
  */
395
250
  declare function createRequestRecording(blobUrlOrData: string | BlobData, handlerPath: string, requestInfo: RequestInfo): Promise<RecordingResult>;
396
251
 
252
+ type SqlFunction$1 = (...args: any[]) => Promise<any[]>;
253
+ interface BackendRequest {
254
+ id: string;
255
+ blob_data: string;
256
+ handler_path: string;
257
+ commit_sha: string;
258
+ branch_name: string;
259
+ repository_url: string | null;
260
+ status: string;
261
+ recording_id: string | null;
262
+ error_message: string | null;
263
+ created_at: string;
264
+ updated_at: string;
265
+ }
397
266
  /**
398
- * Options for spawning a recording container from a blob URL.
399
- * This is the core building block — it knows nothing about request IDs or databases.
267
+ * Creates the `backend_requests` table. Call during schema initialization.
268
+ *
269
+ * Each package client stores captured request data in its own database
270
+ * using this table. The blob data (captured network calls, env reads, etc.)
271
+ * is stored directly in the `blob_data` column rather than in external
272
+ * blob storage.
400
273
  */
401
- interface SpawnRecordingContainerOptions {
402
- /** URL (or data: URI) of the captured request blob JSON. */
403
- blobUrl: string;
404
- /** Handler file path relative to the app root (e.g. "netlify/functions/generate-haiku"). */
274
+ declare function backendRequestsEnsureTable(sql: SqlFunction$1): Promise<void>;
275
+ declare function backendRequestsInsert(sql: SqlFunction$1, data: {
276
+ id?: string;
277
+ blobData: string;
405
278
  handlerPath: string;
406
- /** Git commit SHA to check out inside the container. */
407
279
  commitSha: string;
408
- /** Git branch to clone. */
409
280
  branchName: string;
410
- /** Git repository URL for the app. */
411
- repositoryUrl: string;
412
- /** Infrastructure credentials for Fly.io + Infisical. */
413
- infraConfig: ContainerInfraConfig;
414
- /** Optional webhook URL the container can POST log events to. */
415
- logWebhookUrl?: string;
416
- /** Optional callback for structured log entries. */
417
- onLog?: (level: "info" | "warn" | "error", message: string) => Promise<void>;
418
- }
281
+ repositoryUrl?: string | null;
282
+ }): Promise<string>;
283
+ declare function backendRequestsGet(sql: SqlFunction$1, id: string): Promise<BackendRequest | null>;
284
+ declare function backendRequestsGetBlobData(sql: SqlFunction$1, id: string): Promise<string | null>;
285
+ declare function backendRequestsList(sql: SqlFunction$1, filters?: {
286
+ status?: string;
287
+ limit?: number;
288
+ }): Promise<BackendRequest[]>;
289
+ declare function backendRequestsUpdateStatus(sql: SqlFunction$1, id: string, status: string, recordingId?: string, errorMessage?: string): Promise<void>;
419
290
  /**
420
- * Spawns a detached Fly.io container that:
421
- * 1. Clones the app repo at the correct branch
422
- * 2. Checks out the exact commit
423
- * 3. Runs `scripts/create-request-recording.ts` under replay-node
424
- * 4. Uploads the resulting recording
425
- * 5. Outputs the recording ID
426
- *
427
- * Returns the recording ID on success, or throws on failure.
291
+ * Convenience helper: creates `FinishRequestCallbacks` that store
292
+ * captured request data directly in the `backend_requests` table.
428
293
  */
429
- declare function spawnRecordingContainer(options: SpawnRecordingContainerOptions): Promise<string>;
294
+ declare function databaseCallbacks(sql: SqlFunction$1): FinishRequestCallbacks;
430
295
 
431
296
  type SqlFunction = (...args: any[]) => Promise<any[]>;
432
297
  /**
@@ -451,4 +316,4 @@ declare function databaseAuditDumpLogTable(sql: SqlFunction): Promise<Record<str
451
316
 
452
317
  declare function getCurrentRequestId(): string | null;
453
318
 
454
- export { type BlobData, type CapturedData, type ContainerInfraConfig, type CreateRecordingRequestHandlerOptions, type EnsureRecordingOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type HandlerResponse$1 as HandlerResponse, type NetlifyEvent, type NetlifyV2Request, type NetworkCall, type RecordingResult, type RequestContext, type RequestInfo, type SpawnRecordingContainerOptions, createRecordingRequestHandler, createRequestRecording, databaseAuditDumpLogTable, databaseAuditEnsureLogTable, databaseAuditMonitorTable, ensureRequestRecording, finishRequest, getCurrentRequestId, readInfraConfigFromEnv, redactBlobData, remoteCallbacks, spawnRecordingContainer, startRequest };
319
+ export { type BackendRequest, type BlobData, type CapturedData, type CreateRecordingRequestHandlerOptions, 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, backendRequestsGetBlobData, backendRequestsInsert, backendRequestsList, backendRequestsUpdateStatus, createRecordingRequestHandler, createRequestRecording, databaseAuditDumpLogTable, databaseAuditEnsureLogTable, databaseAuditMonitorTable, databaseCallbacks, finishRequest, getCurrentRequestId, redactBlobData, startRequest };
package/dist/index.js CHANGED
@@ -490,7 +490,6 @@ function redactBlobData(blobData) {
490
490
 
491
491
  // src/finishRequest.ts
492
492
  var SLOW_THRESHOLD_MS = 2e3;
493
- var SLOW_STEP_THRESHOLD_MS = 1e3;
494
493
  async function finishRequest(requestContext, callbacks, response, options) {
495
494
  const finishStart = Date.now();
496
495
  requestContext.cleanup();
@@ -535,34 +534,20 @@ async function finishRequest(requestContext, callbacks, response, options) {
535
534
  };
536
535
  const blobData = redactBlobData(rawBlobData);
537
536
  const blobContent = JSON.stringify(blobData);
538
- const uploadStart = Date.now();
539
- const blobUrl = await callbacks.uploadBlob(blobContent);
540
- const uploadDuration = Date.now() - uploadStart;
541
- if (uploadDuration > SLOW_STEP_THRESHOLD_MS) {
542
- console.warn(
543
- `netlify-recorder: uploadBlob took ${uploadDuration}ms (handler: ${handlerPath})`
544
- );
545
- }
546
537
  const storeStart = Date.now();
547
- const storedRequestId = await callbacks.storeRequestData({
548
- blobUrl,
538
+ const storedRequestId = await callbacks.storeRequest({
539
+ blobData: blobContent,
549
540
  commitSha,
550
541
  branchName,
551
542
  repositoryUrl,
552
543
  handlerPath,
553
- requestId: options?.requestId,
554
- secret: options?.secret
544
+ requestId: options?.requestId
555
545
  });
556
546
  const storeDuration = Date.now() - storeStart;
557
- if (storeDuration > SLOW_STEP_THRESHOLD_MS) {
558
- console.warn(
559
- `netlify-recorder: storeRequestData took ${storeDuration}ms (handler: ${handlerPath})`
560
- );
561
- }
562
547
  const totalDuration = Date.now() - finishStart;
563
548
  if (totalDuration > SLOW_THRESHOLD_MS) {
564
549
  console.warn(
565
- `netlify-recorder: finishRequest took ${totalDuration}ms total (upload: ${uploadDuration}ms, store: ${storeDuration}ms, handler: ${handlerPath})`
550
+ `netlify-recorder: finishRequest took ${totalDuration}ms total (store: ${storeDuration}ms, handler: ${handlerPath})`
566
551
  );
567
552
  }
568
553
  return {
@@ -618,284 +603,6 @@ function createRecordingRequestHandler(handler, options) {
618
603
  };
619
604
  }
620
605
 
621
- // src/remoteCallbacks.ts
622
- function remoteCallbacks(serviceUrl) {
623
- const base = serviceUrl.replace(/\/+$/, "");
624
- let pendingBlobData;
625
- return {
626
- uploadBlob: async (data) => {
627
- pendingBlobData = data;
628
- return "__pending__";
629
- },
630
- storeRequestData: async (metadata) => {
631
- const blobData = pendingBlobData;
632
- pendingBlobData = void 0;
633
- if (!blobData) {
634
- throw new Error(
635
- "remoteCallbacks: uploadBlob must be called before storeRequestData"
636
- );
637
- }
638
- const res = await fetch(
639
- `${base}/api/store-request`,
640
- {
641
- method: "POST",
642
- headers: { "Content-Type": "application/json" },
643
- body: JSON.stringify({
644
- blobData,
645
- handlerPath: metadata.handlerPath,
646
- commitSha: metadata.commitSha,
647
- branchName: metadata.branchName,
648
- repositoryUrl: metadata.repositoryUrl,
649
- requestId: metadata.requestId,
650
- secret: metadata.secret
651
- })
652
- }
653
- );
654
- if (!res.ok) {
655
- const errBody = await res.text().catch(() => "(unreadable)");
656
- throw new Error(
657
- `Netlify Recorder store-request failed: ${res.status} ${errBody}`
658
- );
659
- }
660
- const result = await res.json();
661
- return result.requestId;
662
- }
663
- };
664
- }
665
-
666
- // src/spawnRecordingContainer.ts
667
- async function spawnRecordingContainer(options) {
668
- const {
669
- blobUrl,
670
- handlerPath,
671
- commitSha,
672
- branchName,
673
- repositoryUrl,
674
- infraConfig,
675
- logWebhookUrl,
676
- onLog
677
- } = options;
678
- const emit = async (level, message) => {
679
- if (onLog) {
680
- try {
681
- await onLog(level, message);
682
- } catch {
683
- }
684
- }
685
- };
686
- await emit("info", "Logging in to Infisical");
687
- const {
688
- infisicalLogin,
689
- startContainer,
690
- FileContainerRegistry,
691
- httpGet,
692
- httpOptsFor
693
- } = await import("@replayio/app-building");
694
- const infisicalToken = await infisicalLogin(
695
- infraConfig.infisicalClientId,
696
- infraConfig.infisicalClientSecret
697
- );
698
- const infisicalConfig = {
699
- token: infisicalToken,
700
- projectId: infraConfig.infisicalProjectId,
701
- environment: infraConfig.infisicalEnvironment
702
- };
703
- const registry = new FileContainerRegistry("/tmp/netlify-recorder-containers.json");
704
- const initialPrompt = [
705
- `IMPORTANT: Follow these steps EXACTLY. Run each command as shown. Print ALL output.`,
706
- `Do NOT explore the codebase, read AGENTS.md, or deviate from these steps.`,
707
- `Do NOT attempt to debug, fix, shim, or work around ANY errors. If a command fails,`,
708
- `print the full error output and move on to the next step. Errors during handler replay`,
709
- `(like "No more recorded network calls" or DB errors) are EXPECTED and harmless.`,
710
- ``,
711
- `=== Step 1: Install dependencies ===`,
712
- `cd /repo/apps/netlify-recorder && npm install 2>&1`,
713
- ``,
714
- `=== Step 2: Checkout the exact commit ===`,
715
- `git fetch origin ${commitSha} 2>&1 || git fetch --all 2>&1`,
716
- `git checkout ${commitSha} 2>&1`,
717
- ``,
718
- `=== Step 3: Verify recording script exists ===`,
719
- `ls -la /repo/apps/netlify-recorder/scripts/create-request-recording.ts`,
720
- `If the file does NOT exist, print "ERROR: create-request-recording.ts not found" and STOP.`,
721
- ``,
722
- `=== Step 4: Pre-compile for replay-node (Node v16) ===`,
723
- `replay-node is Node v16 \u2014 it cannot run TypeScript or use modern APIs directly.`,
724
- `You MUST compile everything with esbuild first. Run these commands exactly:`,
725
- ``,
726
- `# Install undici for web API polyfills (fetch, Headers, Response):`,
727
- `cd /repo/apps/netlify-recorder && npm install undici@5 2>&1`,
728
- ``,
729
- `# Create the polyfill loader:`,
730
- `cat > /tmp/web-polyfill.cjs << 'POLYFILL'`,
731
- `try {`,
732
- ` var u = require("undici");`,
733
- ` if (!globalThis.fetch) globalThis.fetch = u.fetch;`,
734
- ` if (!globalThis.Headers) globalThis.Headers = u.Headers;`,
735
- ` if (!globalThis.Response) globalThis.Response = u.Response;`,
736
- ` if (!globalThis.Request) globalThis.Request = u.Request;`,
737
- `} catch(e) { console.error("polyfill warning:", e.message); }`,
738
- `POLYFILL`,
739
- ``,
740
- `# Compile the recording script (bundles all local TS dependencies):`,
741
- `npx esbuild scripts/create-request-recording.ts \\`,
742
- ` --bundle --platform=node --target=node16 --format=cjs \\`,
743
- ` --outfile=/tmp/create-recording.cjs 2>&1`,
744
- ``,
745
- `# Compile the handler (bundles everything including node_modules):`,
746
- `npx esbuild ${handlerPath}.ts \\`,
747
- ` --bundle --platform=node --target=node16 --format=cjs \\`,
748
- ` --outfile=/tmp/handler.cjs 2>&1`,
749
- ``,
750
- `=== Step 5: Run under replay-node ===`,
751
- `This MUST use replay-node so the execution is recorded. Run exactly:`,
752
- ``,
753
- `cd /repo/apps/netlify-recorder && npx @replayio/node \\`,
754
- ` -r /tmp/web-polyfill.cjs /tmp/create-recording.cjs \\`,
755
- ` --blob-url '${blobUrl}' \\`,
756
- ` --handler-path '/tmp/handler.cjs' 2>&1`,
757
- ``,
758
- `The output will show captured data being replayed. Errors like "No more recorded`,
759
- `network calls" or "DATABASE_URL" errors are EXPECTED \u2014 they come from post-handler`,
760
- `DB operations that were not in the original blob. Do NOT try to fix these.`,
761
- ``,
762
- `=== Step 6: Upload the recording ===`,
763
- `exec-secrets RECORD_REPLAY_API_KEY -- npx replayio upload --all 2>&1`,
764
- ``,
765
- `Find the recording ID (UUID) in the upload output and print:`,
766
- ` recording: <recording-id>`,
767
- ``,
768
- `Then output <DONE>.`
769
- ].join("\n");
770
- await emit("info", "Starting detached container on Fly.io");
771
- const state = await startContainer(
772
- {
773
- infisical: infisicalConfig,
774
- registry,
775
- flyToken: infraConfig.flyToken,
776
- flyApp: infraConfig.flyApp,
777
- detached: true,
778
- initialPrompt,
779
- webhookUrl: logWebhookUrl
780
- },
781
- {
782
- repoUrl: repositoryUrl,
783
- cloneBranch: branchName
784
- }
785
- );
786
- await emit("info", `Container started: ${state.containerName} at ${state.baseUrl}`);
787
- const maxWaitMs = 10 * 60 * 1e3;
788
- const pollIntervalMs = 1e4;
789
- const deadline = Date.now() + maxWaitMs;
790
- let containerDone = false;
791
- while (Date.now() < deadline) {
792
- try {
793
- const status = await httpGet(`${state.baseUrl}/status`, httpOptsFor(state));
794
- if (status?.state === "stopped" || status?.state === "stopping") {
795
- containerDone = true;
796
- break;
797
- }
798
- } catch {
799
- containerDone = true;
800
- break;
801
- }
802
- await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
803
- }
804
- if (!containerDone) {
805
- await emit("warn", "Container did not finish within 10 minutes");
806
- }
807
- let recordingId = null;
808
- try {
809
- const logs = await httpGet(`${state.baseUrl}/logs?offset=0`, httpOptsFor(state));
810
- if (typeof logs === "string") {
811
- const match = logs.match(/recording[:\s]+([a-f0-9-]{36})/i);
812
- if (match?.[1]) {
813
- recordingId = match[1];
814
- }
815
- }
816
- } catch {
817
- await emit("warn", "Could not read container logs after exit");
818
- }
819
- if (!recordingId) {
820
- await emit("error", "Container completed but no recording ID was found in output");
821
- throw new Error("Recording creation failed: no recording ID returned from container");
822
- }
823
- await emit("info", `Container completed \u2014 recording ID: ${recordingId}`);
824
- return recordingId;
825
- }
826
-
827
- // src/ensureRequestRecording.ts
828
- async function ensureRequestRecording(requestId, options) {
829
- const { repositoryUrl, infraConfig, webhookUrl, lookupRequest, updateStatus, onLog } = options;
830
- const emit = async (level, message) => {
831
- if (onLog) {
832
- try {
833
- await onLog(level, message);
834
- } catch {
835
- }
836
- }
837
- };
838
- await updateStatus(requestId, "processing");
839
- try {
840
- if (!infraConfig) {
841
- const missing = getMissingInfraVars();
842
- throw new Error(
843
- `Container infrastructure not configured. Missing environment variables: ${missing.join(", ")}. These must be set on the Netlify site for recording creation to work.`
844
- );
845
- }
846
- const requestData = await lookupRequest(requestId);
847
- await emit("info", `Request data retrieved \u2014 handler: ${requestData.handlerPath}, branch: ${requestData.branchName}, commit: ${requestData.commitSha}`);
848
- const recordingId = await spawnRecordingContainer({
849
- blobUrl: requestData.blobUrl,
850
- handlerPath: requestData.handlerPath,
851
- commitSha: requestData.commitSha,
852
- branchName: requestData.branchName,
853
- repositoryUrl,
854
- infraConfig,
855
- logWebhookUrl: webhookUrl,
856
- onLog
857
- });
858
- await emit("info", `Recording created successfully: ${recordingId}`);
859
- await updateStatus(requestId, "recorded", recordingId);
860
- return recordingId;
861
- } catch (err) {
862
- const message = err instanceof Error ? err.message : String(err);
863
- await emit("error", `Recording creation failed: ${message}`);
864
- await updateStatus(requestId, "failed");
865
- throw err;
866
- }
867
- }
868
- function getMissingInfraVars() {
869
- const required = [
870
- "INFISICAL_CLIENT_ID",
871
- "INFISICAL_CLIENT_SECRET",
872
- "INFISICAL_PROJECT_ID",
873
- "INFISICAL_ENVIRONMENT",
874
- "FLY_API_TOKEN",
875
- "FLY_APP_NAME"
876
- ];
877
- return required.filter((name) => !process.env[name]);
878
- }
879
- function readInfraConfigFromEnv() {
880
- const clientId = process.env.INFISICAL_CLIENT_ID;
881
- const clientSecret = process.env.INFISICAL_CLIENT_SECRET;
882
- const projectId = process.env.INFISICAL_PROJECT_ID;
883
- const environment = process.env.INFISICAL_ENVIRONMENT;
884
- const flyToken = process.env.FLY_API_TOKEN;
885
- const flyApp = process.env.FLY_APP_NAME;
886
- if (!clientId || !clientSecret || !projectId || !environment || !flyToken || !flyApp) {
887
- return void 0;
888
- }
889
- return {
890
- infisicalClientId: clientId,
891
- infisicalClientSecret: clientSecret,
892
- infisicalProjectId: projectId,
893
- infisicalEnvironment: environment,
894
- flyToken,
895
- flyApp
896
- };
897
- }
898
-
899
606
  // src/createRequestRecording.ts
900
607
  async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
901
608
  let blobData;
@@ -1203,6 +910,131 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
1203
910
  return result;
1204
911
  }
1205
912
 
913
+ // src/backendRequests.ts
914
+ async function backendRequestsEnsureTable(sql) {
915
+ await sql`
916
+ CREATE TABLE IF NOT EXISTS backend_requests (
917
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
918
+ blob_data TEXT NOT NULL,
919
+ handler_path TEXT NOT NULL,
920
+ commit_sha TEXT NOT NULL,
921
+ branch_name TEXT NOT NULL DEFAULT 'main',
922
+ repository_url TEXT,
923
+ status TEXT NOT NULL DEFAULT 'captured'
924
+ CHECK (status IN ('captured', 'queued', 'processing', 'recorded', 'failed')),
925
+ recording_id TEXT,
926
+ error_message TEXT,
927
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
928
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
929
+ )
930
+ `;
931
+ await sql`
932
+ CREATE INDEX IF NOT EXISTS idx_backend_requests_status ON backend_requests (status)
933
+ `;
934
+ await sql`
935
+ CREATE INDEX IF NOT EXISTS idx_backend_requests_created_at ON backend_requests (created_at DESC)
936
+ `;
937
+ }
938
+ async function backendRequestsInsert(sql, data) {
939
+ if (data.id) {
940
+ await sql`
941
+ INSERT INTO backend_requests (id, blob_data, handler_path, commit_sha, branch_name, repository_url)
942
+ VALUES (
943
+ ${data.id}::uuid,
944
+ ${data.blobData},
945
+ ${data.handlerPath},
946
+ ${data.commitSha},
947
+ ${data.branchName},
948
+ ${data.repositoryUrl ?? null}
949
+ )
950
+ `;
951
+ return data.id;
952
+ }
953
+ const rows = await sql`
954
+ INSERT INTO backend_requests (blob_data, handler_path, commit_sha, branch_name, repository_url)
955
+ VALUES (
956
+ ${data.blobData},
957
+ ${data.handlerPath},
958
+ ${data.commitSha},
959
+ ${data.branchName},
960
+ ${data.repositoryUrl ?? null}
961
+ )
962
+ RETURNING id
963
+ `;
964
+ return rows[0]?.id ?? "";
965
+ }
966
+ async function backendRequestsGet(sql, id) {
967
+ const rows = await sql`
968
+ SELECT id, blob_data, handler_path, commit_sha, branch_name, repository_url,
969
+ status, recording_id, error_message, created_at, updated_at
970
+ FROM backend_requests WHERE id = ${id}
971
+ `;
972
+ return rows[0] ?? null;
973
+ }
974
+ async function backendRequestsGetBlobData(sql, id) {
975
+ const rows = await sql`
976
+ SELECT blob_data FROM backend_requests WHERE id = ${id}
977
+ `;
978
+ return rows[0]?.blob_data ?? null;
979
+ }
980
+ async function backendRequestsList(sql, filters) {
981
+ const limit = filters?.limit ?? 50;
982
+ if (filters?.status) {
983
+ const rows2 = await sql`
984
+ SELECT id, blob_data, handler_path, commit_sha, branch_name, repository_url,
985
+ status, recording_id, error_message, created_at, updated_at
986
+ FROM backend_requests
987
+ WHERE status = ${filters.status}
988
+ ORDER BY created_at DESC
989
+ LIMIT ${limit}
990
+ `;
991
+ return rows2;
992
+ }
993
+ const rows = await sql`
994
+ SELECT id, blob_data, handler_path, commit_sha, branch_name, repository_url,
995
+ status, recording_id, error_message, created_at, updated_at
996
+ FROM backend_requests
997
+ ORDER BY created_at DESC
998
+ LIMIT ${limit}
999
+ `;
1000
+ return rows;
1001
+ }
1002
+ async function backendRequestsUpdateStatus(sql, id, status, recordingId, errorMessage) {
1003
+ if (recordingId) {
1004
+ await sql`
1005
+ UPDATE backend_requests
1006
+ SET status = ${status}, recording_id = ${recordingId}, updated_at = NOW()
1007
+ WHERE id = ${id}
1008
+ `;
1009
+ } else if (errorMessage) {
1010
+ await sql`
1011
+ UPDATE backend_requests
1012
+ SET status = ${status}, error_message = ${errorMessage}, updated_at = NOW()
1013
+ WHERE id = ${id}
1014
+ `;
1015
+ } else {
1016
+ await sql`
1017
+ UPDATE backend_requests
1018
+ SET status = ${status}, updated_at = NOW()
1019
+ WHERE id = ${id}
1020
+ `;
1021
+ }
1022
+ }
1023
+ function databaseCallbacks(sql) {
1024
+ return {
1025
+ storeRequest: async (data) => {
1026
+ return backendRequestsInsert(sql, {
1027
+ id: data.requestId,
1028
+ blobData: data.blobData,
1029
+ handlerPath: data.handlerPath,
1030
+ commitSha: data.commitSha,
1031
+ branchName: data.branchName,
1032
+ repositoryUrl: data.repositoryUrl
1033
+ });
1034
+ }
1035
+ };
1036
+ }
1037
+
1206
1038
  // src/databaseAudit.ts
1207
1039
  async function databaseAuditEnsureLogTable(sql) {
1208
1040
  await sql`
@@ -1283,17 +1115,20 @@ async function databaseAuditDumpLogTable(sql) {
1283
1115
  return rows;
1284
1116
  }
1285
1117
  export {
1118
+ backendRequestsEnsureTable,
1119
+ backendRequestsGet,
1120
+ backendRequestsGetBlobData,
1121
+ backendRequestsInsert,
1122
+ backendRequestsList,
1123
+ backendRequestsUpdateStatus,
1286
1124
  createRecordingRequestHandler,
1287
1125
  createRequestRecording,
1288
1126
  databaseAuditDumpLogTable,
1289
1127
  databaseAuditEnsureLogTable,
1290
1128
  databaseAuditMonitorTable,
1291
- ensureRequestRecording,
1129
+ databaseCallbacks,
1292
1130
  finishRequest,
1293
1131
  getCurrentRequestId,
1294
- readInfraConfigFromEnv,
1295
1132
  redactBlobData,
1296
- remoteCallbacks,
1297
- spawnRecordingContainer,
1298
1133
  startRequest
1299
1134
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replayio-app-building/netlify-recorder",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Capture and replay Netlify function executions as Replay recordings",
5
5
  "type": "module",
6
6
  "exports": {