@replayio-app-building/netlify-recorder 0.16.0 → 0.17.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
@@ -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 as a blob, 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 via the Netlify Recorder service, and can later reproduce the exact execution as a Replay recording for debugging and analysis.
4
4
 
5
5
  ## Installation
6
6
 
@@ -8,23 +8,11 @@ Capture and replay Netlify function executions as [Replay](https://replay.io) re
8
8
  npm install @replayio-app-building/netlify-recorder
9
9
  ```
10
10
 
11
- ## Integration Options
12
-
13
- There are two ways to use this package:
14
-
15
- - **Option A: Use the Netlify Recorder service (recommended)** — The service handles blob storage, request tracking, and recording creation. Your app just wraps its handlers and calls `remoteCallbacks()`. No database or container infrastructure needed.
16
-
17
- - **Option B: Self-hosted** — You manage your own blob storage, database tables, and recording containers. Full control, but requires more setup.
18
-
19
- ---
20
-
21
- ## Option A: Using the Netlify Recorder Service
22
-
23
- The Netlify Recorder app (`https://netlify-recorder-bm4wmw.netlify.app`) provides a hosted service that stores captured request data, manages a pool of recording containers, and creates Replay recordings on demand. Your app needs zero database or infrastructure setup.
11
+ ## Setup
24
12
 
25
13
  ### 1. Set required environment variables
26
14
 
27
- `finishRequest` needs to know which repository, branch, and commit your deployed code belongs to. Set these three environment variables on your Netlify site:
15
+ `finishRequest` needs to know which repository, branch, and commit your deployed code belongs to. Set these environment variables on your Netlify site:
28
16
 
29
17
  | Variable | Description | How to set |
30
18
  |---|---|---|
@@ -115,7 +103,7 @@ export default createRecordingRequestHandler(
115
103
 
116
104
  ### 3. Create recordings
117
105
 
118
- When you want to turn a captured request into a Replay recording, POST to the service's `create-recording` endpoint with the request ID. If the request was created with a secret, you must include it:
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:
119
107
 
120
108
  ```typescript
121
109
  const response = await fetch(
@@ -132,7 +120,7 @@ const response = await fetch(
132
120
  );
133
121
  ```
134
122
 
135
- The service looks up the stored blob data, dispatches the work to a pool container, and creates the recording.
123
+ The service looks up the stored blob data, dispatches the work to a recording container, and creates the recording.
136
124
 
137
125
  If `webhookUrl` is provided, the service will POST the result to that URL when the recording completes (or fails):
138
126
 
@@ -225,7 +213,7 @@ Requests created without a secret remain accessible without one (backward compat
225
213
 
226
214
  Store your secret as a `NETLIFY_RECORDER_SECRET` environment variable. To keep it secure:
227
215
 
228
- 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.
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.
229
217
 
230
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:
231
219
 
@@ -239,158 +227,6 @@ Use a strong random string (e.g. `openssl rand -base64 32`) and rotate it if com
239
227
 
240
228
  ---
241
229
 
242
- ## Option B: Self-Hosted
243
-
244
- If you need full control, you can manage your own blob storage, database, and recording containers. This requires more setup but gives you complete ownership of the data pipeline.
245
-
246
- ### 1. Set required environment variables
247
-
248
- Same as Option A — you must set `REPLAY_REPOSITORY_URL`, `COMMIT_SHA`, and `BRANCH_NAME` on your Netlify site. See the table in Option A, Step 1 above.
249
-
250
- ### 2. Wrap your Netlify function
251
-
252
- Use `createRecordingRequestHandler` with custom callbacks that write to your own storage and database:
253
-
254
- ```typescript
255
- import { createRecordingRequestHandler } from "@replayio-app-building/netlify-recorder";
256
-
257
- const handler = createRecordingRequestHandler(
258
- async (event) => {
259
- const result = await myBusinessLogic();
260
-
261
- return {
262
- statusCode: 200,
263
- headers: { "Content-Type": "application/json" },
264
- body: JSON.stringify(result),
265
- };
266
- },
267
- {
268
- secret: process.env.NETLIFY_RECORDER_SECRET,
269
- callbacks: {
270
- uploadBlob: async (data) => {
271
- // Upload the JSON string to your blob storage (S3, R2, etc.)
272
- const res = await fetch("https://storage.example.com/upload", {
273
- method: "PUT",
274
- body: data,
275
- });
276
- const { url } = await res.json();
277
- return url;
278
- },
279
- storeRequestData: async ({ blobUrl, commitSha, branchName, repositoryUrl, handlerPath, secret }) => {
280
- const [row] = await sql`
281
- INSERT INTO requests (blob_url, commit_sha, branch_name, repository_url, handler_path, secret, status)
282
- VALUES (${blobUrl}, ${commitSha}, ${branchName}, ${repositoryUrl}, ${handlerPath}, ${secret}, 'captured')
283
- RETURNING id
284
- `;
285
- return row.id;
286
- },
287
- },
288
- }
289
- );
290
-
291
- export { handler };
292
- ```
293
-
294
- ### 3. Create a requests database table
295
-
296
- ```sql
297
- CREATE TABLE IF NOT EXISTS requests (
298
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
299
- blob_url TEXT,
300
- commit_sha TEXT,
301
- branch_name TEXT,
302
- repository_url TEXT,
303
- handler_path TEXT,
304
- secret TEXT,
305
- recording_id TEXT,
306
- status TEXT NOT NULL DEFAULT 'captured'
307
- CHECK (status IN ('captured', 'processing', 'recorded', 'failed')),
308
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
309
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
310
- );
311
-
312
- CREATE INDEX IF NOT EXISTS idx_requests_secret ON requests (secret) WHERE secret IS NOT NULL;
313
- ```
314
-
315
- ### 4. Create a background function to produce recordings
316
-
317
- ```typescript
318
- import { ensureRequestRecording } from "@replayio-app-building/netlify-recorder";
319
- import type { Handler } from "@netlify/functions";
320
-
321
- const handler: Handler = async (event) => {
322
- const { requestId } = JSON.parse(event.body ?? "{}");
323
-
324
- const recordingId = await ensureRequestRecording(requestId, {
325
- repositoryUrl: process.env.APP_REPOSITORY_URL!,
326
- lookupRequest: async (id) => {
327
- const [row] = await sql`
328
- SELECT blob_url, commit_sha, branch_name, handler_path
329
- FROM requests WHERE id = ${id}
330
- `;
331
- return {
332
- blobUrl: row.blob_url,
333
- commitSha: row.commit_sha,
334
- branchName: row.branch_name ?? "main",
335
- handlerPath: row.handler_path,
336
- };
337
- },
338
- updateStatus: async (id, status, recordingId) => {
339
- await sql`
340
- UPDATE requests
341
- SET status = ${status},
342
- recording_id = ${recordingId ?? null},
343
- updated_at = NOW()
344
- WHERE id = ${id}
345
- `;
346
- },
347
- });
348
-
349
- return {
350
- statusCode: 200,
351
- body: JSON.stringify({ recordingId }),
352
- };
353
- };
354
-
355
- export { handler };
356
- ```
357
-
358
- ### 5. Create a container script
359
-
360
- This script runs inside the recording container under `replay-node`:
361
-
362
- ```typescript
363
- // scripts/create-request-recording.ts
364
- import { createRequestRecording } from "@replayio-app-building/netlify-recorder";
365
-
366
- const args = process.argv.slice(2);
367
- const blobUrl = args[args.indexOf("--blob-url") + 1]!;
368
- const handlerPath = args[args.indexOf("--handler-path") + 1]!;
369
-
370
- await createRequestRecording(blobUrl, handlerPath, {
371
- method: "POST",
372
- url: handlerPath,
373
- headers: {},
374
- });
375
- ```
376
-
377
- ### Required infrastructure
378
-
379
- Self-hosted recording requires these environment variables:
380
-
381
- | Variable | Description |
382
- |---|---|
383
- | `INFISICAL_CLIENT_ID` | Infisical service account client ID |
384
- | `INFISICAL_CLIENT_SECRET` | Infisical service account client secret |
385
- | `INFISICAL_PROJECT_ID` | Infisical project ID |
386
- | `INFISICAL_ENVIRONMENT` | Infisical environment (e.g. `production`) |
387
- | `FLY_API_TOKEN` | Fly.io API token for container management |
388
- | `FLY_APP_NAME` | Fly.io app name for container deployment |
389
- | `APP_REPOSITORY_URL` | Git repository URL for container cloning |
390
- | `RECORD_REPLAY_API_KEY` | Replay API key for recording upload |
391
-
392
- ---
393
-
394
230
  ## Audit Log Support
395
231
 
396
232
  The package automatically tracks database mutations (INSERT, UPDATE, DELETE) in an `audit_log` table and links each change to the Replay request that caused it. When your handler is wrapped with `createRecordingRequestHandler`, all Neon SQL queries are automatically tagged with the request ID — no changes to your SQL code required.
@@ -484,7 +320,7 @@ When `context.waitUntil` is not available (v1 handlers or missing context), the
484
320
 
485
321
  **Parameters:**
486
322
  - `handler` — Your async handler function `(event, context?) => Promise<{ statusCode, headers?, body? }>`
487
- - `options.callbacks` — Either `remoteCallbacks(serviceUrl)` or custom `{ uploadBlob, storeRequestData }` callbacks
323
+ - `options.callbacks` — `remoteCallbacks(serviceUrl)` for sending captured data to the Netlify Recorder service
488
324
  - `options.handlerPath` — Path to the handler file (used for recording metadata)
489
325
  - `options.commitSha` — Override `COMMIT_SHA` env var
490
326
  - `options.branchName` — Override `BRANCH_NAME` env var
@@ -520,7 +356,7 @@ Logs a `console.warn` when the total duration exceeds 2 seconds or when individu
520
356
 
521
357
  **Parameters:**
522
358
  - `requestContext` — The context returned by `startRequest`
523
- - `callbacks` — Either `remoteCallbacks(serviceUrl)` or custom `{ uploadBlob, storeRequestData }` callbacks
359
+ - `callbacks` — `remoteCallbacks(serviceUrl)` for sending captured data to the Netlify Recorder service
524
360
  - `response` — The handler's response object (`{ statusCode, headers?, body? }`)
525
361
  - `options.handlerPath` — Path to the handler file (used for recording metadata)
526
362
  - `options.commitSha` — Override `COMMIT_SHA` env var
@@ -531,30 +367,11 @@ Logs a `console.warn` when the total duration exceeds 2 seconds or when individu
531
367
 
532
368
  ### `remoteCallbacks(serviceUrl): FinishRequestCallbacks`
533
369
 
534
- Creates callbacks for `finishRequest` that send captured data to the Netlify Recorder service. The service handles blob storage and request tracking — no local database needed.
370
+ Creates callbacks for `finishRequest` that send captured data to the Netlify Recorder service. The service handles blob storage and request tracking.
535
371
 
536
372
  **Parameters:**
537
373
  - `serviceUrl` — Base URL of the Netlify Recorder service (e.g. `"https://netlify-recorder-bm4wmw.netlify.app"`)
538
374
 
539
- ### `ensureRequestRecording(requestId, options): Promise<string>`
540
-
541
- Spawns a container via `@replayio/app-building` to create a Replay recording from captured request data. Returns the recording ID. Only needed for self-hosted setups (Option B).
542
-
543
- **Parameters:**
544
- - `requestId` — The request to create a recording for
545
- - `options.repositoryUrl` — Git repository URL for the container to clone
546
- - `options.lookupRequest(id)` — Fetches `{ blobUrl, commitSha, branchName, handlerPath }` from the database
547
- - `options.updateStatus(id, status, recordingId?)` — Updates the request status in the database
548
-
549
- ### `createRequestRecording(blobUrl, handlerPath, requestInfo): Promise<void>`
550
-
551
- Called inside a container running under `replay-node`. Downloads the captured data blob, installs replay-mode interceptors (which return pre-recorded responses instead of making real calls), and executes the original handler so replay-node can record the execution. Only needed for self-hosted setups (Option B).
552
-
553
- **Parameters:**
554
- - `blobUrl` — URL to the captured data blob
555
- - `handlerPath` — Path to the handler module to execute
556
- - `requestInfo` — The original request info to replay
557
-
558
375
  ### `databaseAuditEnsureLogTable(sql): Promise<void>`
559
376
 
560
377
  Creates the `audit_log` table, its indexes, and a reusable PL/pgSQL trigger function (`audit_trigger_function`). Call once during schema initialization.
@@ -579,8 +396,6 @@ Returns all rows from the `audit_log` table, ordered by `performed_at` DESC.
579
396
 
580
397
  ## Environment Variables
581
398
 
582
- ### Required for all setups (read by `finishRequest`)
583
-
584
399
  These must be set on your Netlify site. Your deploy script should resolve them from git and push them to the Netlify environment before deploying. `finishRequest` will throw an error if any are missing.
585
400
 
586
401
  | Variable | Description | How to resolve |
@@ -590,15 +405,8 @@ These must be set on your Netlify site. Your deploy script should resolve them f
590
405
  | `REPLAY_REPOSITORY_URL` | Git repository URL (no embedded credentials) | `git remote get-url origin` (strip tokens) |
591
406
  | `NETLIFY_RECORDER_SECRET` | Secret for access control (strongly recommended) | `openssl rand -base64 32` — store in Netlify site env vars |
592
407
 
593
- ### Required for self-hosted recording (Option B)
594
-
595
- | Variable | Description |
596
- |---|---|
597
- | `RECORD_REPLAY_API_KEY` | Replay API key for uploading recordings |
598
- | `APP_REPOSITORY_URL` | Git repository URL for container cloning |
599
-
600
408
  ## How It Works
601
409
 
602
- 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 either the remote service (via `remoteCallbacks`) or your own storage (via custom callbacks).
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`.
603
411
 
604
- 2. **Recording phase**: The captured blob is sent to a recording container (either via the Netlify Recorder service or self-hosted). Inside the container, `createRequestRecording` downloads the blob, installs replay-mode interceptors that return the pre-recorded responses, 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.
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.
package/dist/index.d.ts CHANGED
@@ -93,24 +93,66 @@ 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>;
96
98
  /**
97
- * Stores the captured request data and returns the request ID.
99
+ * Stores request metadata in the database and returns the request ID.
98
100
  *
99
101
  * When `requestId` is provided (from `createRecordingRequestHandler`'s
100
102
  * `waitUntil` flow), the callback should use it as the row ID so the
101
- * client-facing header and the stored record match. When omitted, the
102
- * callback generates its own ID.
103
+ * client-facing header and the stored record match. When omitted, the
104
+ * callback generates its own ID (backward-compatible).
103
105
  */
104
- storeRequest: (data: {
105
- blobData: string;
106
+ storeRequestData: (data: {
107
+ blobUrl: string;
106
108
  commitSha: string;
107
109
  branchName: string;
108
110
  repositoryUrl: string;
109
111
  handlerPath: string;
110
112
  /** Pre-generated request ID. Use as the row ID when provided. */
111
113
  requestId?: string;
114
+ /** Optional secret that restricts access to this request. */
115
+ secret?: string;
112
116
  }) => Promise<string>;
113
117
  }
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
+ }
114
156
 
115
157
  /**
116
158
  * Called at the beginning of a Netlify handler execution.
@@ -170,16 +212,32 @@ interface FinishRequestOptions {
170
212
  repositoryUrl?: string;
171
213
  /**
172
214
  * Pre-generated request ID. When provided, this ID is passed to the
173
- * `storeRequest` callback so the stored row matches the ID already
215
+ * `storeRequestData` callback so the stored row matches the ID already
174
216
  * 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.
175
220
  */
176
221
  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;
177
227
  }
178
228
  /**
179
229
  * Called at the end of the handler execution.
180
230
  * Restores original globals, serializes all captured data,
181
- * stores the request via the provided callback, and sets the
182
- * X-Replay-Request-Id header.
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.
183
241
  */
184
242
  declare function finishRequest(requestContext: RequestContext, callbacks: FinishRequestCallbacks, response: FullHandlerResponse, options?: FinishRequestOptions): Promise<FullHandlerResponse>;
185
243
 
@@ -196,15 +254,80 @@ interface CreateRecordingRequestHandlerOptions extends FinishRequestOptions {
196
254
  *
197
255
  * Automatically calls `startRequest` before the handler and `finishRequest`
198
256
  * after, capturing all outbound network calls and environment variable reads.
199
- * The captured data is stored via the provided callbacks.
257
+ * On error, interceptors are cleaned up and the error is re-thrown.
200
258
  *
201
259
  * **Response timing:** When the Netlify Functions v2 `context` object is
202
260
  * available (with `waitUntil`), the response is returned to the client
203
261
  * **immediately** with a pre-generated `X-Replay-Request-Id` header. The
204
- * data storage continues in the background via `context.waitUntil()`.
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
+ * ```
205
314
  */
206
315
  declare function createRecordingRequestHandler(handler: (event: NetlifyEvent | NetlifyV2Request, context?: unknown) => Promise<HandlerResponse>, options: CreateRecordingRequestHandlerOptions): (event: NetlifyEvent | NetlifyV2Request, context?: unknown) => Promise<HandlerResponse>;
207
316
 
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
+
208
331
  /**
209
332
  * Redacts sensitive environment variable values from blob data.
210
333
  *
@@ -225,6 +348,28 @@ declare function createRecordingRequestHandler(handler: (event: NetlifyEvent | N
225
348
  */
226
349
  declare function redactBlobData(blobData: BlobData): BlobData;
227
350
 
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
+
228
373
  interface RecordingResult {
229
374
  /** Whether a response mismatch was detected between capture and replay. */
230
375
  responseMismatch: boolean;
@@ -249,49 +394,39 @@ interface RecordingResult {
249
394
  */
250
395
  declare function createRequestRecording(blobUrlOrData: string | BlobData, handlerPath: string, requestInfo: RequestInfo): Promise<RecordingResult>;
251
396
 
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
- }
266
397
  /**
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.
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.
273
400
  */
274
- declare function backendRequestsEnsureTable(sql: SqlFunction$1): Promise<void>;
275
- declare function backendRequestsInsert(sql: SqlFunction$1, data: {
276
- id?: string;
277
- blobData: string;
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"). */
278
405
  handlerPath: string;
406
+ /** Git commit SHA to check out inside the container. */
279
407
  commitSha: string;
408
+ /** Git branch to clone. */
280
409
  branchName: string;
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>;
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
+ }
290
419
  /**
291
- * Convenience helper: creates `FinishRequestCallbacks` that store
292
- * captured request data directly in the `backend_requests` table.
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.
293
428
  */
294
- declare function databaseCallbacks(sql: SqlFunction$1): FinishRequestCallbacks;
429
+ declare function spawnRecordingContainer(options: SpawnRecordingContainerOptions): Promise<string>;
295
430
 
296
431
  type SqlFunction = (...args: any[]) => Promise<any[]>;
297
432
  /**
@@ -316,4 +451,4 @@ declare function databaseAuditDumpLogTable(sql: SqlFunction): Promise<Record<str
316
451
 
317
452
  declare function getCurrentRequestId(): string | null;
318
453
 
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 };
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 };
package/dist/index.js CHANGED
@@ -490,6 +490,7 @@ function redactBlobData(blobData) {
490
490
 
491
491
  // src/finishRequest.ts
492
492
  var SLOW_THRESHOLD_MS = 2e3;
493
+ var SLOW_STEP_THRESHOLD_MS = 1e3;
493
494
  async function finishRequest(requestContext, callbacks, response, options) {
494
495
  const finishStart = Date.now();
495
496
  requestContext.cleanup();
@@ -534,20 +535,34 @@ async function finishRequest(requestContext, callbacks, response, options) {
534
535
  };
535
536
  const blobData = redactBlobData(rawBlobData);
536
537
  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
+ }
537
546
  const storeStart = Date.now();
538
- const storedRequestId = await callbacks.storeRequest({
539
- blobData: blobContent,
547
+ const storedRequestId = await callbacks.storeRequestData({
548
+ blobUrl,
540
549
  commitSha,
541
550
  branchName,
542
551
  repositoryUrl,
543
552
  handlerPath,
544
- requestId: options?.requestId
553
+ requestId: options?.requestId,
554
+ secret: options?.secret
545
555
  });
546
556
  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
+ }
547
562
  const totalDuration = Date.now() - finishStart;
548
563
  if (totalDuration > SLOW_THRESHOLD_MS) {
549
564
  console.warn(
550
- `netlify-recorder: finishRequest took ${totalDuration}ms total (store: ${storeDuration}ms, handler: ${handlerPath})`
565
+ `netlify-recorder: finishRequest took ${totalDuration}ms total (upload: ${uploadDuration}ms, store: ${storeDuration}ms, handler: ${handlerPath})`
551
566
  );
552
567
  }
553
568
  return {
@@ -603,6 +618,284 @@ function createRecordingRequestHandler(handler, options) {
603
618
  };
604
619
  }
605
620
 
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
+
606
899
  // src/createRequestRecording.ts
607
900
  async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
608
901
  let blobData;
@@ -910,131 +1203,6 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
910
1203
  return result;
911
1204
  }
912
1205
 
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
-
1038
1206
  // src/databaseAudit.ts
1039
1207
  async function databaseAuditEnsureLogTable(sql) {
1040
1208
  await sql`
@@ -1115,20 +1283,17 @@ async function databaseAuditDumpLogTable(sql) {
1115
1283
  return rows;
1116
1284
  }
1117
1285
  export {
1118
- backendRequestsEnsureTable,
1119
- backendRequestsGet,
1120
- backendRequestsGetBlobData,
1121
- backendRequestsInsert,
1122
- backendRequestsList,
1123
- backendRequestsUpdateStatus,
1124
1286
  createRecordingRequestHandler,
1125
1287
  createRequestRecording,
1126
1288
  databaseAuditDumpLogTable,
1127
1289
  databaseAuditEnsureLogTable,
1128
1290
  databaseAuditMonitorTable,
1129
- databaseCallbacks,
1291
+ ensureRequestRecording,
1130
1292
  finishRequest,
1131
1293
  getCurrentRequestId,
1294
+ readInfraConfigFromEnv,
1132
1295
  redactBlobData,
1296
+ remoteCallbacks,
1297
+ spawnRecordingContainer,
1133
1298
  startRequest
1134
1299
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replayio-app-building/netlify-recorder",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Capture and replay Netlify function executions as Replay recordings",
5
5
  "type": "module",
6
6
  "exports": {