@replayio-app-building/netlify-recorder 0.34.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -113,7 +113,63 @@ export default createRecordingRequestHandler(
113
113
 
114
114
  > **Note:** Always use the response returned by the wrapper (or `finishRequest`), not your original response object. The wrapper adds the `X-Replay-Request-Id` header to the response it returns.
115
115
 
116
- ### 4. Create recordings via the Netlify Recorder service
116
+ ### 4. Expose a recording endpoint for other services
117
+
118
+ Use `createRecordingEndpoint` to create a standalone Netlify function that other services can call to trigger recording creation or check recording status. This is the simplest way to let external services interact with the `backend_requests` table without importing the full package.
119
+
120
+ ```typescript
121
+ // netlify/functions/ensure-recording.ts
122
+ import { createRecordingEndpoint } from "@replayio-app-building/netlify-recorder";
123
+ import { neon } from "@neondatabase/serverless";
124
+
125
+ const sql = neon(process.env.DATABASE_URL!);
126
+
127
+ export default createRecordingEndpoint({
128
+ sql,
129
+ recorderUrl: "https://netlify-recorder-bm4wmw.netlify.app",
130
+ // Optional: require callers to authenticate with a shared secret
131
+ secret: process.env.RECORDER_ENDPOINT_SECRET,
132
+ // Optional: receive a webhook when the recording completes
133
+ webhookUrl: "https://my-app.netlify.app/.netlify/functions/recording-webhook",
134
+ });
135
+ ```
136
+
137
+ **Calling the endpoint from another service:**
138
+
139
+ ```typescript
140
+ // Trigger recording creation (POST)
141
+ const res = await fetch("https://my-app.netlify.app/.netlify/functions/ensure-recording", {
142
+ method: "POST",
143
+ headers: {
144
+ "Content-Type": "application/json",
145
+ // Include if `secret` is configured:
146
+ "Authorization": "Bearer my-shared-secret",
147
+ },
148
+ body: JSON.stringify({ requestId: "a1b2c3d4-..." }),
149
+ });
150
+ const result = await res.json();
151
+ // { status: "queued", requestId: "a1b2c3d4-..." }
152
+ // or { status: "recorded", recordingId: "...", requestId: "..." }
153
+ // or { status: "pending", requestId: "..." }
154
+
155
+ // Check status without triggering (GET)
156
+ const status = await fetch(
157
+ "https://my-app.netlify.app/.netlify/functions/ensure-recording?requestId=a1b2c3d4-...",
158
+ { headers: { "Authorization": "Bearer my-shared-secret" } },
159
+ );
160
+ ```
161
+
162
+ **Response statuses:**
163
+
164
+ | Status | HTTP Code | Meaning |
165
+ |--------|-----------|---------|
166
+ | `recorded` | 200 | Recording exists — `recordingId` is included |
167
+ | `pending` | 200 | Recording is queued or processing — check back later |
168
+ | `queued` | 202 | Recording was just queued by this POST call |
169
+ | `not_found` | 404 | Request ID not found in `backend_requests` |
170
+ | `error` | 4xx/5xx | Validation error, auth failure, or recording failure |
171
+
172
+ ### 5. Create recordings programmatically
117
173
 
118
174
  Use `ensureRequestRecording` to turn a captured request into a Replay recording. It checks the `backend_requests` table first — if a recording already exists, it returns the recording ID immediately without calling the service. Otherwise it passes the stored blob data URL to the Netlify Recorder service and updates the row status to `"queued"`.
119
175
 
@@ -150,7 +206,7 @@ On failure:
150
206
  { "status": "failed", "error": "Error message" }
151
207
  ```
152
208
 
153
- ### 5. Manage stored requests
209
+ ### 6. Manage stored requests
154
210
 
155
211
  Use the `backendRequests*` helpers to query and manage captured requests in your database:
156
212
 
@@ -410,6 +466,30 @@ Ensures a Replay recording exists (or is being created) for a backend request. L
410
466
 
411
467
  **Throws:** If the request ID is not found in `backend_requests`, or if the service call fails.
412
468
 
469
+ ### `createRecordingEndpoint(options): (req: Request) => Promise<Response>`
470
+
471
+ Creates a Netlify Function v2 handler that other services can call to trigger recording creation or check recording status for entries in the `backend_requests` table.
472
+
473
+ Supports **POST** (trigger recording) and **GET** (check status). When `secret` is provided, all requests must include an `Authorization: Bearer <secret>` header.
474
+
475
+ **Parameters:**
476
+ - `options.sql` — A Neon SQL tagged-template function
477
+ - `options.recorderUrl` — Base URL of the Netlify Recorder service
478
+ - `options.secret` — Shared secret for authentication (optional — when omitted, the endpoint is open)
479
+ - `options.webhookUrl` — URL to POST the recording result to when complete (optional)
480
+
481
+ **Returns:** An async function `(req: Request) => Promise<Response>` suitable as a Netlify Functions v2 default export.
482
+
483
+ **POST body:** `{ "requestId": "<uuid>" }` — triggers recording if needed.
484
+
485
+ **GET query:** `?requestId=<uuid>` — returns current status without triggering.
486
+
487
+ **Response body** (`RecordingEndpointResponse`):
488
+ - `status` — `"recorded"`, `"pending"`, `"queued"`, `"not_found"`, or `"error"`
489
+ - `recordingId` — Present when `status` is `"recorded"`
490
+ - `requestId` — The request ID echoed back
491
+ - `error` — Error message when `status` is `"not_found"` or `"error"`
492
+
413
493
  ### `createRequestRecording(blobUrlOrData, handlerPath, requestInfo): Promise<RecordingResult>`
414
494
 
415
495
  Called inside a recording container running under `replay-node`. Downloads the captured data blob (or accepts pre-parsed `BlobData`), installs replay-mode interceptors that return pre-recorded responses instead of making real calls, and executes the original handler so `replay-node` can record the execution.
package/dist/index.d.ts CHANGED
@@ -235,7 +235,7 @@ interface RecordingResult {
235
235
  */
236
236
  declare function createRequestRecording(blobUrlOrData: string | BlobData, handlerPath: string, requestInfo: RequestInfo): Promise<RecordingResult>;
237
237
 
238
- type SqlFunction$1 = (...args: any[]) => Promise<any[]>;
238
+ type SqlFunction$2 = (...args: any[]) => Promise<any[]>;
239
239
  interface BackendRequest {
240
240
  id: string;
241
241
  blob_data_url: string;
@@ -256,8 +256,8 @@ interface BackendRequest {
256
256
  * using this table. The blob data (captured network calls, env reads, etc.)
257
257
  * is uploaded to UploadThing at capture time and only the URL is stored.
258
258
  */
259
- declare function backendRequestsEnsureTable(sql: SqlFunction$1): Promise<void>;
260
- declare function backendRequestsInsert(sql: SqlFunction$1, data: {
259
+ declare function backendRequestsEnsureTable(sql: SqlFunction$2): Promise<void>;
260
+ declare function backendRequestsInsert(sql: SqlFunction$2, data: {
261
261
  id?: string;
262
262
  blobDataUrl: string;
263
263
  handlerPath: string;
@@ -265,13 +265,13 @@ declare function backendRequestsInsert(sql: SqlFunction$1, data: {
265
265
  branchName: string;
266
266
  repositoryUrl?: string | null;
267
267
  }): Promise<string>;
268
- declare function backendRequestsGet(sql: SqlFunction$1, id: string): Promise<BackendRequest | null>;
269
- declare function backendRequestsGetBlobUrl(sql: SqlFunction$1, id: string): Promise<string | null>;
270
- declare function backendRequestsList(sql: SqlFunction$1, filters?: {
268
+ declare function backendRequestsGet(sql: SqlFunction$2, id: string): Promise<BackendRequest | null>;
269
+ declare function backendRequestsGetBlobUrl(sql: SqlFunction$2, id: string): Promise<string | null>;
270
+ declare function backendRequestsList(sql: SqlFunction$2, filters?: {
271
271
  status?: string;
272
272
  limit?: number;
273
273
  }): Promise<BackendRequest[]>;
274
- declare function backendRequestsUpdateStatus(sql: SqlFunction$1, id: string, status: string, recordingId?: string, errorMessage?: string): Promise<void>;
274
+ declare function backendRequestsUpdateStatus(sql: SqlFunction$2, id: string, status: string, recordingId?: string, errorMessage?: string): Promise<void>;
275
275
  /**
276
276
  * Convenience helper: creates `FinishRequestCallbacks` that upload
277
277
  * captured request data to UploadThing and store the URL in the
@@ -279,7 +279,15 @@ declare function backendRequestsUpdateStatus(sql: SqlFunction$1, id: string, sta
279
279
  *
280
280
  * Requires `UPLOADTHING_TOKEN` environment variable and the `uploadthing` package.
281
281
  */
282
- declare function databaseCallbacks(sql: SqlFunction$1): FinishRequestCallbacks;
282
+ declare function databaseCallbacks(sql: SqlFunction$2): FinishRequestCallbacks;
283
+ /**
284
+ * Creates `FinishRequestCallbacks` that POST captured request data to a
285
+ * remote Netlify Recorder service's `/api/store-request` endpoint.
286
+ *
287
+ * Use this instead of `databaseCallbacks` when the app does not own the
288
+ * recorder database — the hosted service handles blob upload and storage.
289
+ */
290
+ declare function remoteCallbacks(recorderUrl: string): FinishRequestCallbacks;
283
291
  interface EnsureRequestRecordingOptions {
284
292
  /** Base URL of the Netlify Recorder service (e.g. "https://netlify-recorder-bm4wmw.netlify.app"). */
285
293
  recorderUrl: string;
@@ -299,9 +307,9 @@ interface EnsureRequestRecordingOptions {
299
307
  * This function is idempotent — calling it multiple times for the same request
300
308
  * is safe. Once the recording completes, subsequent calls return the recording ID.
301
309
  */
302
- declare function ensureRequestRecording(sql: SqlFunction$1, requestId: string, options: EnsureRequestRecordingOptions): Promise<string | null>;
310
+ declare function ensureRequestRecording(sql: SqlFunction$2, requestId: string, options: EnsureRequestRecordingOptions): Promise<string | null>;
303
311
 
304
- type SqlFunction = (...args: any[]) => Promise<any[]>;
312
+ type SqlFunction$1 = (...args: any[]) => Promise<any[]>;
305
313
  /**
306
314
  * Creates the `audit_log` table and a generic PL/pgSQL trigger function
307
315
  * (`audit_trigger_function`) that records INSERT, UPDATE, and DELETE
@@ -309,20 +317,56 @@ type SqlFunction = (...args: any[]) => Promise<any[]>;
309
317
  *
310
318
  * Call this once during schema initialization.
311
319
  */
312
- declare function databaseAuditEnsureLogTable(sql: SqlFunction): Promise<void>;
320
+ declare function databaseAuditEnsureLogTable(sql: SqlFunction$1): Promise<void>;
313
321
  /**
314
322
  * Creates a trigger on the specified table that calls
315
323
  * `audit_trigger_function` for INSERT, UPDATE, and DELETE operations.
316
324
  *
317
325
  * Throws if `tableName` is `'audit_log'` (cannot monitor itself).
318
326
  */
319
- declare function databaseAuditMonitorTable(sql: SqlFunction, tableName: string, primaryKeyColumn?: string): Promise<void>;
327
+ declare function databaseAuditMonitorTable(sql: SqlFunction$1, tableName: string, primaryKeyColumn?: string): Promise<void>;
320
328
  /**
321
329
  * Returns all rows from the `audit_log` table, ordered by `performed_at` DESC.
322
330
  */
323
- declare function databaseAuditDumpLogTable(sql: SqlFunction): Promise<Record<string, unknown>[]>;
331
+ declare function databaseAuditDumpLogTable(sql: SqlFunction$1): Promise<Record<string, unknown>[]>;
332
+
333
+ type SqlFunction = (...args: any[]) => Promise<any[]>;
334
+ interface CreateRecordingEndpointOptions {
335
+ sql: SqlFunction;
336
+ recorderUrl: string;
337
+ secret?: string;
338
+ webhookUrl?: string;
339
+ }
340
+ interface RecordingEndpointResponse {
341
+ status: "recorded" | "pending" | "queued" | "not_found" | "error";
342
+ recordingId?: string;
343
+ requestId?: string;
344
+ error?: string;
345
+ }
346
+ /**
347
+ * Creates a Netlify Function handler that other services can call to trigger
348
+ * recording creation for a backend request (or retrieve its current status).
349
+ *
350
+ * **POST** with `{ "requestId": "<uuid>" }` — triggers recording creation if
351
+ * needed and returns the current status. Idempotent: re-posting the same
352
+ * request ID will not re-queue an already-queued or recorded request.
353
+ *
354
+ * **GET** with `?requestId=<uuid>` — returns the current recording status
355
+ * without triggering any recording.
356
+ *
357
+ * When `secret` is provided, every request must include an
358
+ * `Authorization: Bearer <secret>` header or receive a 401 response.
359
+ *
360
+ * Response body shape (`RecordingEndpointResponse`):
361
+ * - `{ status: "recorded", recordingId, requestId }` — recording exists
362
+ * - `{ status: "pending", requestId }` — recording is queued or processing
363
+ * - `{ status: "queued", requestId }` — recording was just queued by this call
364
+ * - `{ status: "not_found", error }` — request ID not in backend_requests
365
+ * - `{ status: "error", requestId?, error }` — recording failed or server error
366
+ */
367
+ declare function createRecordingEndpoint(options: CreateRecordingEndpointOptions): (req: Request) => Promise<Response>;
324
368
 
325
369
  declare function runInRequestContext<T>(requestId: string | null, fn: () => Promise<T>): Promise<T>;
326
370
  declare function getCurrentRequestId(): string | null;
327
371
 
328
- export { type BackendRequest, type BlobData, type CapturedData, type CreateRecordingRequestHandlerOptions, type EnsureRequestRecordingOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type HandlerResponse$1 as HandlerResponse, type NetlifyEvent, type NetlifyV2Request, type NetworkCall, type RecordingResult, type RequestContext, type RequestInfo, backendRequestsEnsureTable, backendRequestsGet, backendRequestsGetBlobUrl, backendRequestsInsert, backendRequestsList, backendRequestsUpdateStatus, createRecordingRequestHandler, createRequestRecording, databaseAuditDumpLogTable, databaseAuditEnsureLogTable, databaseAuditMonitorTable, databaseCallbacks, ensureRequestRecording, finishRequest, getCurrentRequestId, redactBlobData, runInRequestContext, startRequest };
372
+ export { type BackendRequest, type BlobData, type CapturedData, type CreateRecordingEndpointOptions, type CreateRecordingRequestHandlerOptions, type EnsureRequestRecordingOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type HandlerResponse$1 as HandlerResponse, type NetlifyEvent, type NetlifyV2Request, type NetworkCall, type RecordingEndpointResponse, type RecordingResult, type RequestContext, type RequestInfo, backendRequestsEnsureTable, backendRequestsGet, backendRequestsGetBlobUrl, backendRequestsInsert, backendRequestsList, backendRequestsUpdateStatus, createRecordingEndpoint, createRecordingRequestHandler, createRequestRecording, databaseAuditDumpLogTable, databaseAuditEnsureLogTable, databaseAuditMonitorTable, databaseCallbacks, ensureRequestRecording, finishRequest, getCurrentRequestId, redactBlobData, remoteCallbacks, runInRequestContext, startRequest };
package/dist/index.js CHANGED
@@ -716,8 +716,8 @@ async function finishRequest(requestContext, callbacks, response, options) {
716
716
  startTime: requestContext.startTime,
717
717
  endTime: Date.now(),
718
718
  handlerResponse: {
719
- statusCode: response.statusCode,
720
- body: response.body
719
+ statusCode: response.statusCode ?? response.status ?? 0,
720
+ body: typeof response.body === "string" ? response.body : void 0
721
721
  }
722
722
  };
723
723
  const blobData = redactBlobData(rawBlobData);
@@ -756,7 +756,18 @@ function createRecordingRequestHandler(handler, options) {
756
756
  const reqContext = startRequest(event);
757
757
  let response;
758
758
  try {
759
- response = await handler(event, context);
759
+ const rawResponse = await handler(event, context);
760
+ if (rawResponse && typeof rawResponse.status === "number" && typeof rawResponse.text === "function") {
761
+ const v2 = rawResponse;
762
+ const bodyText = await v2.text();
763
+ response = {
764
+ statusCode: v2.status,
765
+ body: bodyText,
766
+ headers: v2.headers?.entries ? Object.fromEntries(v2.headers.entries()) : void 0
767
+ };
768
+ } else {
769
+ response = rawResponse;
770
+ }
760
771
  } catch (handlerErr) {
761
772
  const errorMessage = handlerErr instanceof Error ? handlerErr.message : String(handlerErr);
762
773
  const errorResponse = {
@@ -974,6 +985,9 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
974
985
  headers["content-type"] = "application/json";
975
986
  return new ResponseShim(body, { ...init, headers });
976
987
  }
988
+ get body() {
989
+ return this._body;
990
+ }
977
991
  async text() {
978
992
  return this._body ?? "";
979
993
  }
@@ -1091,8 +1105,8 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
1091
1105
  );
1092
1106
  }
1093
1107
  if (bodyMismatch) {
1094
- const capturedPreview = (blobData.handlerResponse.body ?? "").slice(0, 200);
1095
- const replayPreview = (replayResponse.body ?? "").slice(0, 200);
1108
+ const capturedPreview = String(blobData.handlerResponse.body ?? "").slice(0, 200);
1109
+ const replayPreview = String(replayResponse.body ?? "").slice(0, 200);
1096
1110
  details.push(
1097
1111
  `Body differs: captured=${JSON.stringify(capturedPreview)}, replay=${JSON.stringify(replayPreview)}`
1098
1112
  );
@@ -1280,6 +1294,33 @@ function databaseCallbacks(sql) {
1280
1294
  }
1281
1295
  };
1282
1296
  }
1297
+ function remoteCallbacks(recorderUrl) {
1298
+ const baseUrl = recorderUrl.replace(/\/+$/, "");
1299
+ return {
1300
+ storeRequest: async (data) => {
1301
+ const res = await fetch(`${baseUrl}/api/store-request`, {
1302
+ method: "POST",
1303
+ headers: { "Content-Type": "application/json" },
1304
+ body: JSON.stringify({
1305
+ blobData: data.blobData,
1306
+ commitSha: data.commitSha,
1307
+ branchName: data.branchName,
1308
+ repositoryUrl: data.repositoryUrl,
1309
+ handlerPath: data.handlerPath,
1310
+ requestId: data.requestId
1311
+ })
1312
+ });
1313
+ if (!res.ok) {
1314
+ const errBody = await res.text().catch(() => "(unreadable)");
1315
+ throw new Error(
1316
+ `netlify-recorder: remote store-request failed: ${res.status} ${errBody}`
1317
+ );
1318
+ }
1319
+ const result = await res.json();
1320
+ return result.requestId;
1321
+ }
1322
+ };
1323
+ }
1283
1324
  async function ensureRequestRecording(sql, requestId, options) {
1284
1325
  const request = await backendRequestsGet(sql, requestId);
1285
1326
  if (!request) {
@@ -1405,6 +1446,92 @@ async function databaseAuditDumpLogTable(sql) {
1405
1446
  const rows = await sql`SELECT * FROM audit_log ORDER BY performed_at DESC`;
1406
1447
  return rows;
1407
1448
  }
1449
+
1450
+ // src/createRecordingEndpoint.ts
1451
+ function jsonResponse(body, status) {
1452
+ return new Response(JSON.stringify(body), {
1453
+ status,
1454
+ headers: { "Content-Type": "application/json" }
1455
+ });
1456
+ }
1457
+ function formatStatus(request) {
1458
+ if (request.status === "recorded" && request.recording_id) {
1459
+ return { status: "recorded", recordingId: request.recording_id, requestId: request.id };
1460
+ }
1461
+ if (request.status === "queued" || request.status === "processing") {
1462
+ return { status: "pending", requestId: request.id };
1463
+ }
1464
+ if (request.status === "failed") {
1465
+ return { status: "error", requestId: request.id, error: request.error_message ?? "Recording failed" };
1466
+ }
1467
+ return { status: "pending", requestId: request.id };
1468
+ }
1469
+ function createRecordingEndpoint(options) {
1470
+ const { sql, recorderUrl, secret, webhookUrl } = options;
1471
+ return async (req) => {
1472
+ if (secret) {
1473
+ const auth = req.headers.get("authorization");
1474
+ if (auth !== `Bearer ${secret}`) {
1475
+ return jsonResponse({ status: "error", error: "Unauthorized" }, 401);
1476
+ }
1477
+ }
1478
+ try {
1479
+ if (req.method === "GET") {
1480
+ const url = new URL(req.url);
1481
+ const requestId = url.searchParams.get("requestId");
1482
+ if (!requestId) {
1483
+ return jsonResponse({ status: "error", error: "Missing requestId query parameter" }, 400);
1484
+ }
1485
+ const request = await backendRequestsGet(sql, requestId);
1486
+ if (!request) {
1487
+ return jsonResponse({ status: "not_found", error: `Request ${requestId} not found` }, 404);
1488
+ }
1489
+ return jsonResponse(formatStatus(request), 200);
1490
+ }
1491
+ if (req.method === "POST") {
1492
+ const body = await req.json();
1493
+ const requestId = body.requestId;
1494
+ if (!requestId) {
1495
+ return jsonResponse({ status: "error", error: "Missing requestId in request body" }, 400);
1496
+ }
1497
+ const request = await backendRequestsGet(sql, requestId);
1498
+ if (!request) {
1499
+ return jsonResponse({ status: "not_found", error: `Request ${requestId} not found` }, 404);
1500
+ }
1501
+ if (request.status === "recorded" && request.recording_id) {
1502
+ return jsonResponse(
1503
+ { status: "recorded", recordingId: request.recording_id, requestId },
1504
+ 200
1505
+ );
1506
+ }
1507
+ if (request.status === "queued" || request.status === "processing") {
1508
+ return jsonResponse({ status: "pending", requestId }, 200);
1509
+ }
1510
+ try {
1511
+ const recordingId = await ensureRequestRecording(sql, requestId, {
1512
+ recorderUrl,
1513
+ webhookUrl
1514
+ });
1515
+ if (recordingId) {
1516
+ return jsonResponse({ status: "recorded", recordingId, requestId }, 200);
1517
+ }
1518
+ return jsonResponse({ status: "queued", requestId }, 202);
1519
+ } catch (err) {
1520
+ const message = err instanceof Error ? err.message : String(err);
1521
+ await backendRequestsUpdateStatus(sql, requestId, "failed", void 0, message).catch(
1522
+ () => {
1523
+ }
1524
+ );
1525
+ return jsonResponse({ status: "error", requestId, error: message }, 502);
1526
+ }
1527
+ }
1528
+ return jsonResponse({ status: "error", error: "Method not allowed" }, 405);
1529
+ } catch (err) {
1530
+ const message = err instanceof Error ? err.message : String(err);
1531
+ return jsonResponse({ status: "error", error: message }, 500);
1532
+ }
1533
+ };
1534
+ }
1408
1535
  export {
1409
1536
  backendRequestsEnsureTable,
1410
1537
  backendRequestsGet,
@@ -1412,6 +1539,7 @@ export {
1412
1539
  backendRequestsInsert,
1413
1540
  backendRequestsList,
1414
1541
  backendRequestsUpdateStatus,
1542
+ createRecordingEndpoint,
1415
1543
  createRecordingRequestHandler,
1416
1544
  createRequestRecording,
1417
1545
  databaseAuditDumpLogTable,
@@ -1422,6 +1550,7 @@ export {
1422
1550
  finishRequest,
1423
1551
  getCurrentRequestId,
1424
1552
  redactBlobData,
1553
+ remoteCallbacks,
1425
1554
  runInRequestContext,
1426
1555
  startRequest
1427
1556
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replayio-app-building/netlify-recorder",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "description": "Capture and replay Netlify function executions as Replay recordings",
5
5
  "type": "module",
6
6
  "exports": {