@replayio-app-building/netlify-recorder 0.15.10 → 0.16.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 (3) hide show
  1. package/dist/index.d.ts +48 -183
  2. package/dist/index.js +141 -329
  3. package/package.json +1 -1
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,79 +196,14 @@ 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
- * ```
314
- */
315
- declare function createRecordingRequestHandler(handler: (event: NetlifyEvent | NetlifyV2Request, context?: unknown) => Promise<HandlerResponse>, options: CreateRecordingRequestHandlerOptions): (event: NetlifyEvent | NetlifyV2Request, context?: unknown) => Promise<HandlerResponse | Response>;
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")
204
+ * data storage continues in the background via `context.waitUntil()`.
328
205
  */
329
- declare function remoteCallbacks(serviceUrl: string): FinishRequestCallbacks;
206
+ declare function createRecordingRequestHandler(handler: (event: NetlifyEvent | NetlifyV2Request, context?: unknown) => Promise<HandlerResponse>, options: CreateRecordingRequestHandlerOptions): (event: NetlifyEvent | NetlifyV2Request, context?: unknown) => Promise<HandlerResponse>;
330
207
 
331
208
  /**
332
209
  * Redacts sensitive environment variable values from blob data.
@@ -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 {
@@ -578,7 +563,6 @@ async function finishRequest(requestContext, callbacks, response, options) {
578
563
  import crypto from "crypto";
579
564
  function createRecordingRequestHandler(handler, options) {
580
565
  return async (event, context) => {
581
- const isV2 = isWebApiRequest(event);
582
566
  const requestId = crypto.randomUUID();
583
567
  setCurrentRequestId(requestId);
584
568
  const reqContext = startRequest(event);
@@ -612,294 +596,10 @@ function createRecordingRequestHandler(handler, options) {
612
596
  }
613
597
  )
614
598
  );
615
- return isV2 ? toWebResponse(responseWithHeader) : responseWithHeader;
599
+ return responseWithHeader;
616
600
  }
617
601
  await finishRequest(reqContext, options.callbacks, response, finishOpts);
618
- return isV2 ? toWebResponse(responseWithHeader) : responseWithHeader;
619
- };
620
- }
621
- function toWebResponse(result) {
622
- return new Response(result.body, {
623
- status: result.statusCode,
624
- headers: result.headers
625
- });
626
- }
627
-
628
- // src/remoteCallbacks.ts
629
- function remoteCallbacks(serviceUrl) {
630
- const base = serviceUrl.replace(/\/+$/, "");
631
- let pendingBlobData;
632
- return {
633
- uploadBlob: async (data) => {
634
- pendingBlobData = data;
635
- return "__pending__";
636
- },
637
- storeRequestData: async (metadata) => {
638
- const blobData = pendingBlobData;
639
- pendingBlobData = void 0;
640
- if (!blobData) {
641
- throw new Error(
642
- "remoteCallbacks: uploadBlob must be called before storeRequestData"
643
- );
644
- }
645
- const res = await fetch(
646
- `${base}/api/store-request`,
647
- {
648
- method: "POST",
649
- headers: { "Content-Type": "application/json" },
650
- body: JSON.stringify({
651
- blobData,
652
- handlerPath: metadata.handlerPath,
653
- commitSha: metadata.commitSha,
654
- branchName: metadata.branchName,
655
- repositoryUrl: metadata.repositoryUrl,
656
- requestId: metadata.requestId,
657
- secret: metadata.secret
658
- })
659
- }
660
- );
661
- if (!res.ok) {
662
- const errBody = await res.text().catch(() => "(unreadable)");
663
- throw new Error(
664
- `Netlify Recorder store-request failed: ${res.status} ${errBody}`
665
- );
666
- }
667
- const result = await res.json();
668
- return result.requestId;
669
- }
670
- };
671
- }
672
-
673
- // src/spawnRecordingContainer.ts
674
- async function spawnRecordingContainer(options) {
675
- const {
676
- blobUrl,
677
- handlerPath,
678
- commitSha,
679
- branchName,
680
- repositoryUrl,
681
- infraConfig,
682
- logWebhookUrl,
683
- onLog
684
- } = options;
685
- const emit = async (level, message) => {
686
- if (onLog) {
687
- try {
688
- await onLog(level, message);
689
- } catch {
690
- }
691
- }
692
- };
693
- await emit("info", "Logging in to Infisical");
694
- const {
695
- infisicalLogin,
696
- startContainer,
697
- FileContainerRegistry,
698
- httpGet,
699
- httpOptsFor
700
- } = await import("@replayio/app-building");
701
- const infisicalToken = await infisicalLogin(
702
- infraConfig.infisicalClientId,
703
- infraConfig.infisicalClientSecret
704
- );
705
- const infisicalConfig = {
706
- token: infisicalToken,
707
- projectId: infraConfig.infisicalProjectId,
708
- environment: infraConfig.infisicalEnvironment
709
- };
710
- const registry = new FileContainerRegistry("/tmp/netlify-recorder-containers.json");
711
- const initialPrompt = [
712
- `IMPORTANT: Follow these steps EXACTLY. Run each command as shown. Print ALL output.`,
713
- `Do NOT explore the codebase, read AGENTS.md, or deviate from these steps.`,
714
- `Do NOT attempt to debug, fix, shim, or work around ANY errors. If a command fails,`,
715
- `print the full error output and move on to the next step. Errors during handler replay`,
716
- `(like "No more recorded network calls" or DB errors) are EXPECTED and harmless.`,
717
- ``,
718
- `=== Step 1: Install dependencies ===`,
719
- `cd /repo/apps/netlify-recorder && npm install 2>&1`,
720
- ``,
721
- `=== Step 2: Checkout the exact commit ===`,
722
- `git fetch origin ${commitSha} 2>&1 || git fetch --all 2>&1`,
723
- `git checkout ${commitSha} 2>&1`,
724
- ``,
725
- `=== Step 3: Verify recording script exists ===`,
726
- `ls -la /repo/apps/netlify-recorder/scripts/create-request-recording.ts`,
727
- `If the file does NOT exist, print "ERROR: create-request-recording.ts not found" and STOP.`,
728
- ``,
729
- `=== Step 4: Pre-compile for replay-node (Node v16) ===`,
730
- `replay-node is Node v16 \u2014 it cannot run TypeScript or use modern APIs directly.`,
731
- `You MUST compile everything with esbuild first. Run these commands exactly:`,
732
- ``,
733
- `# Install undici for web API polyfills (fetch, Headers, Response):`,
734
- `cd /repo/apps/netlify-recorder && npm install undici@5 2>&1`,
735
- ``,
736
- `# Create the polyfill loader:`,
737
- `cat > /tmp/web-polyfill.cjs << 'POLYFILL'`,
738
- `try {`,
739
- ` var u = require("undici");`,
740
- ` if (!globalThis.fetch) globalThis.fetch = u.fetch;`,
741
- ` if (!globalThis.Headers) globalThis.Headers = u.Headers;`,
742
- ` if (!globalThis.Response) globalThis.Response = u.Response;`,
743
- ` if (!globalThis.Request) globalThis.Request = u.Request;`,
744
- `} catch(e) { console.error("polyfill warning:", e.message); }`,
745
- `POLYFILL`,
746
- ``,
747
- `# Compile the recording script (bundles all local TS dependencies):`,
748
- `npx esbuild scripts/create-request-recording.ts \\`,
749
- ` --bundle --platform=node --target=node16 --format=cjs \\`,
750
- ` --outfile=/tmp/create-recording.cjs 2>&1`,
751
- ``,
752
- `# Compile the handler (bundles everything including node_modules):`,
753
- `npx esbuild ${handlerPath}.ts \\`,
754
- ` --bundle --platform=node --target=node16 --format=cjs \\`,
755
- ` --outfile=/tmp/handler.cjs 2>&1`,
756
- ``,
757
- `=== Step 5: Run under replay-node ===`,
758
- `This MUST use replay-node so the execution is recorded. Run exactly:`,
759
- ``,
760
- `cd /repo/apps/netlify-recorder && npx @replayio/node \\`,
761
- ` -r /tmp/web-polyfill.cjs /tmp/create-recording.cjs \\`,
762
- ` --blob-url '${blobUrl}' \\`,
763
- ` --handler-path '/tmp/handler.cjs' 2>&1`,
764
- ``,
765
- `The output will show captured data being replayed. Errors like "No more recorded`,
766
- `network calls" or "DATABASE_URL" errors are EXPECTED \u2014 they come from post-handler`,
767
- `DB operations that were not in the original blob. Do NOT try to fix these.`,
768
- ``,
769
- `=== Step 6: Upload the recording ===`,
770
- `exec-secrets RECORD_REPLAY_API_KEY -- npx replayio upload --all 2>&1`,
771
- ``,
772
- `Find the recording ID (UUID) in the upload output and print:`,
773
- ` recording: <recording-id>`,
774
- ``,
775
- `Then output <DONE>.`
776
- ].join("\n");
777
- await emit("info", "Starting detached container on Fly.io");
778
- const state = await startContainer(
779
- {
780
- infisical: infisicalConfig,
781
- registry,
782
- flyToken: infraConfig.flyToken,
783
- flyApp: infraConfig.flyApp,
784
- detached: true,
785
- initialPrompt,
786
- webhookUrl: logWebhookUrl
787
- },
788
- {
789
- repoUrl: repositoryUrl,
790
- cloneBranch: branchName
791
- }
792
- );
793
- await emit("info", `Container started: ${state.containerName} at ${state.baseUrl}`);
794
- const maxWaitMs = 10 * 60 * 1e3;
795
- const pollIntervalMs = 1e4;
796
- const deadline = Date.now() + maxWaitMs;
797
- let containerDone = false;
798
- while (Date.now() < deadline) {
799
- try {
800
- const status = await httpGet(`${state.baseUrl}/status`, httpOptsFor(state));
801
- if (status?.state === "stopped" || status?.state === "stopping") {
802
- containerDone = true;
803
- break;
804
- }
805
- } catch {
806
- containerDone = true;
807
- break;
808
- }
809
- await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
810
- }
811
- if (!containerDone) {
812
- await emit("warn", "Container did not finish within 10 minutes");
813
- }
814
- let recordingId = null;
815
- try {
816
- const logs = await httpGet(`${state.baseUrl}/logs?offset=0`, httpOptsFor(state));
817
- if (typeof logs === "string") {
818
- const match = logs.match(/recording[:\s]+([a-f0-9-]{36})/i);
819
- if (match?.[1]) {
820
- recordingId = match[1];
821
- }
822
- }
823
- } catch {
824
- await emit("warn", "Could not read container logs after exit");
825
- }
826
- if (!recordingId) {
827
- await emit("error", "Container completed but no recording ID was found in output");
828
- throw new Error("Recording creation failed: no recording ID returned from container");
829
- }
830
- await emit("info", `Container completed \u2014 recording ID: ${recordingId}`);
831
- return recordingId;
832
- }
833
-
834
- // src/ensureRequestRecording.ts
835
- async function ensureRequestRecording(requestId, options) {
836
- const { repositoryUrl, infraConfig, webhookUrl, lookupRequest, updateStatus, onLog } = options;
837
- const emit = async (level, message) => {
838
- if (onLog) {
839
- try {
840
- await onLog(level, message);
841
- } catch {
842
- }
843
- }
844
- };
845
- await updateStatus(requestId, "processing");
846
- try {
847
- if (!infraConfig) {
848
- const missing = getMissingInfraVars();
849
- throw new Error(
850
- `Container infrastructure not configured. Missing environment variables: ${missing.join(", ")}. These must be set on the Netlify site for recording creation to work.`
851
- );
852
- }
853
- const requestData = await lookupRequest(requestId);
854
- await emit("info", `Request data retrieved \u2014 handler: ${requestData.handlerPath}, branch: ${requestData.branchName}, commit: ${requestData.commitSha}`);
855
- const recordingId = await spawnRecordingContainer({
856
- blobUrl: requestData.blobUrl,
857
- handlerPath: requestData.handlerPath,
858
- commitSha: requestData.commitSha,
859
- branchName: requestData.branchName,
860
- repositoryUrl,
861
- infraConfig,
862
- logWebhookUrl: webhookUrl,
863
- onLog
864
- });
865
- await emit("info", `Recording created successfully: ${recordingId}`);
866
- await updateStatus(requestId, "recorded", recordingId);
867
- return recordingId;
868
- } catch (err) {
869
- const message = err instanceof Error ? err.message : String(err);
870
- await emit("error", `Recording creation failed: ${message}`);
871
- await updateStatus(requestId, "failed");
872
- throw err;
873
- }
874
- }
875
- function getMissingInfraVars() {
876
- const required = [
877
- "INFISICAL_CLIENT_ID",
878
- "INFISICAL_CLIENT_SECRET",
879
- "INFISICAL_PROJECT_ID",
880
- "INFISICAL_ENVIRONMENT",
881
- "FLY_API_TOKEN",
882
- "FLY_APP_NAME"
883
- ];
884
- return required.filter((name) => !process.env[name]);
885
- }
886
- function readInfraConfigFromEnv() {
887
- const clientId = process.env.INFISICAL_CLIENT_ID;
888
- const clientSecret = process.env.INFISICAL_CLIENT_SECRET;
889
- const projectId = process.env.INFISICAL_PROJECT_ID;
890
- const environment = process.env.INFISICAL_ENVIRONMENT;
891
- const flyToken = process.env.FLY_API_TOKEN;
892
- const flyApp = process.env.FLY_APP_NAME;
893
- if (!clientId || !clientSecret || !projectId || !environment || !flyToken || !flyApp) {
894
- return void 0;
895
- }
896
- return {
897
- infisicalClientId: clientId,
898
- infisicalClientSecret: clientSecret,
899
- infisicalProjectId: projectId,
900
- infisicalEnvironment: environment,
901
- flyToken,
902
- flyApp
602
+ return responseWithHeader;
903
603
  };
904
604
  }
905
605
 
@@ -1210,6 +910,131 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
1210
910
  return result;
1211
911
  }
1212
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
+
1213
1038
  // src/databaseAudit.ts
1214
1039
  async function databaseAuditEnsureLogTable(sql) {
1215
1040
  await sql`
@@ -1236,23 +1061,7 @@ async function databaseAuditEnsureLogTable(sql) {
1236
1061
  changed_cols TEXT[];
1237
1062
  req_id TEXT;
1238
1063
  call_idx INTEGER;
1239
- pk_col TEXT;
1240
- pk_val TEXT;
1241
1064
  BEGIN
1242
- -- Dynamically look up the primary key column for this table
1243
- SELECT a.attname INTO pk_col
1244
- FROM pg_index i
1245
- JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
1246
- WHERE i.indrelid = TG_RELID AND i.indisprimary
1247
- LIMIT 1;
1248
-
1249
- -- Extract PK value from the appropriate record
1250
- IF TG_OP = 'DELETE' THEN
1251
- EXECUTE format('SELECT ($1.%I)::TEXT', pk_col) INTO pk_val USING OLD;
1252
- ELSE
1253
- EXECUTE format('SELECT ($1.%I)::TEXT', pk_col) INTO pk_val USING NEW;
1254
- END IF;
1255
-
1256
1065
  -- Read application context injected by the network interceptor
1257
1066
  req_id := COALESCE(current_setting('app.replay_request_id', true), '');
1258
1067
  IF req_id = '' THEN req_id := NULL; END IF;
@@ -1265,7 +1074,7 @@ async function databaseAuditEnsureLogTable(sql) {
1265
1074
 
1266
1075
  IF TG_OP = 'INSERT' THEN
1267
1076
  INSERT INTO audit_log (table_name, record_id, action, new_data, replay_request_id, replay_request_call_index)
1268
- VALUES (TG_TABLE_NAME, pk_val, 'INSERT', to_jsonb(NEW), req_id, call_idx);
1077
+ VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'INSERT', to_jsonb(NEW), req_id, call_idx);
1269
1078
  RETURN NEW;
1270
1079
  ELSIF TG_OP = 'UPDATE' THEN
1271
1080
  SELECT ARRAY_AGG(n.key) INTO changed_cols
@@ -1274,11 +1083,11 @@ async function databaseAuditEnsureLogTable(sql) {
1274
1083
  WHERE o.value IS DISTINCT FROM n.value;
1275
1084
 
1276
1085
  INSERT INTO audit_log (table_name, record_id, action, old_data, new_data, changed_fields, replay_request_id, replay_request_call_index)
1277
- VALUES (TG_TABLE_NAME, pk_val, 'UPDATE', to_jsonb(OLD), to_jsonb(NEW), COALESCE(changed_cols, ARRAY[]::TEXT[]), req_id, call_idx);
1086
+ VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'UPDATE', to_jsonb(OLD), to_jsonb(NEW), COALESCE(changed_cols, ARRAY[]::TEXT[]), req_id, call_idx);
1278
1087
  RETURN NEW;
1279
1088
  ELSIF TG_OP = 'DELETE' THEN
1280
1089
  INSERT INTO audit_log (table_name, record_id, action, old_data, replay_request_id, replay_request_call_index)
1281
- VALUES (TG_TABLE_NAME, pk_val, 'DELETE', to_jsonb(OLD), req_id, call_idx);
1090
+ VALUES (TG_TABLE_NAME, OLD.id::TEXT, 'DELETE', to_jsonb(OLD), req_id, call_idx);
1282
1091
  RETURN OLD;
1283
1092
  END IF;
1284
1093
  RETURN NULL;
@@ -1306,17 +1115,20 @@ async function databaseAuditDumpLogTable(sql) {
1306
1115
  return rows;
1307
1116
  }
1308
1117
  export {
1118
+ backendRequestsEnsureTable,
1119
+ backendRequestsGet,
1120
+ backendRequestsGetBlobData,
1121
+ backendRequestsInsert,
1122
+ backendRequestsList,
1123
+ backendRequestsUpdateStatus,
1309
1124
  createRecordingRequestHandler,
1310
1125
  createRequestRecording,
1311
1126
  databaseAuditDumpLogTable,
1312
1127
  databaseAuditEnsureLogTable,
1313
1128
  databaseAuditMonitorTable,
1314
- ensureRequestRecording,
1129
+ databaseCallbacks,
1315
1130
  finishRequest,
1316
1131
  getCurrentRequestId,
1317
- readInfraConfigFromEnv,
1318
1132
  redactBlobData,
1319
- remoteCallbacks,
1320
- spawnRecordingContainer,
1321
1133
  startRequest
1322
1134
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replayio-app-building/netlify-recorder",
3
- "version": "0.15.10",
3
+ "version": "0.16.0",
4
4
  "description": "Capture and replay Netlify function executions as Replay recordings",
5
5
  "type": "module",
6
6
  "exports": {