@replayio-app-building/netlify-recorder 0.15.9 → 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 +47 -182
  2. package/dist/index.js +136 -301
  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,80 +196,15 @@ interface CreateRecordingRequestHandlerOptions extends FinishRequestOptions {
254
196
  *
255
197
  * Automatically calls `startRequest` before the handler and `finishRequest`
256
198
  * after, capturing all outbound network calls and environment variable reads.
257
- * On error, interceptors are cleaned up and the error is re-thrown.
199
+ * The captured data is stored via the provided callbacks.
258
200
  *
259
201
  * **Response timing:** When the Netlify Functions v2 `context` object is
260
202
  * available (with `waitUntil`), the response is returned to the client
261
203
  * **immediately** with a pre-generated `X-Replay-Request-Id` header. The
262
- * blob upload and metadata storage continue in the background via
263
- * `context.waitUntil()`. This avoids adding latency to the client response.
264
- *
265
- * When `context.waitUntil` is not available (v1 handlers or missing context),
266
- * the wrapper falls back to awaiting `finishRequest` before returning.
267
- *
268
- * For v2 handlers the request body is read from a clone internally — your
269
- * handler still receives the original request with an unconsumed body.
270
- *
271
- * @example v1 handler (NetlifyEvent)
272
- * ```typescript
273
- * import { createRecordingRequestHandler, remoteCallbacks } from "@replayio-app-building/netlify-recorder";
274
- *
275
- * const handler = createRecordingRequestHandler(
276
- * async (event) => {
277
- * const result = await myBusinessLogic(event.body);
278
- * return {
279
- * statusCode: 200,
280
- * headers: { "Content-Type": "application/json" },
281
- * body: JSON.stringify(result),
282
- * };
283
- * },
284
- * {
285
- * callbacks: remoteCallbacks("https://netlify-recorder-bm4wmw.netlify.app"),
286
- * handlerPath: "netlify/functions/my-handler",
287
- * }
288
- * );
289
- *
290
- * export { handler };
291
- * ```
292
- *
293
- * @example v2 handler (Web API Request)
294
- * ```typescript
295
- * import { createRecordingRequestHandler, remoteCallbacks } from "@replayio-app-building/netlify-recorder";
296
- *
297
- * export default createRecordingRequestHandler(
298
- * async (req) => {
299
- * // Body is still available — startRequest reads from a clone
300
- * const body = await (req as Request).json();
301
- * const result = await myBusinessLogic(body);
302
- * return {
303
- * statusCode: 200,
304
- * headers: { "Content-Type": "application/json" },
305
- * body: JSON.stringify(result),
306
- * };
307
- * },
308
- * {
309
- * callbacks: remoteCallbacks("https://netlify-recorder-bm4wmw.netlify.app"),
310
- * handlerPath: "netlify/functions/my-handler",
311
- * }
312
- * );
313
- * ```
204
+ * data storage continues in the background via `context.waitUntil()`.
314
205
  */
315
206
  declare function createRecordingRequestHandler(handler: (event: NetlifyEvent | NetlifyV2Request, context?: unknown) => Promise<HandlerResponse>, options: CreateRecordingRequestHandlerOptions): (event: NetlifyEvent | NetlifyV2Request, context?: unknown) => Promise<HandlerResponse>;
316
207
 
317
- /**
318
- * Creates `FinishRequestCallbacks` that send captured data to a remote
319
- * Netlify Recorder service. This removes the need for the consuming app
320
- * to set up its own blob storage or database tables.
321
- *
322
- * The callbacks upload blob data and store request metadata via the
323
- * service's `/api/store-request` endpoint, which handles UploadThing
324
- * storage and database insertion.
325
- *
326
- * @param serviceUrl - Base URL of the Netlify Recorder service
327
- * (e.g. "https://netlify-recorder-bm4wmw.netlify.app")
328
- */
329
- declare function remoteCallbacks(serviceUrl: string): FinishRequestCallbacks;
330
-
331
208
  /**
332
209
  * Redacts sensitive environment variable values from blob data.
333
210
  *
@@ -348,28 +225,6 @@ declare function remoteCallbacks(serviceUrl: string): FinishRequestCallbacks;
348
225
  */
349
226
  declare function redactBlobData(blobData: BlobData): BlobData;
350
227
 
351
- /**
352
- * Called by a background function to convert a request ID into a Replay recording ID.
353
- *
354
- * The function:
355
- * 1. Looks up request metadata (blob URL, commit, handler path).
356
- * 2. Delegates to `spawnRecordingContainer` which starts a detached Fly.io
357
- * container, runs the recording script under replay-node, and uploads
358
- * the resulting recording.
359
- * 3. Updates the request status with the recording ID.
360
- *
361
- * **Required infrastructure:** Infisical credentials and a Fly.io token/app.
362
- * See `ContainerInfraConfig` in types.ts for details. When these are not
363
- * configured the function fails with an actionable error message listing
364
- * the missing environment variables.
365
- */
366
- declare function ensureRequestRecording(requestId: string, options: EnsureRecordingOptions): Promise<string>;
367
- /**
368
- * Reads infrastructure config from environment variables.
369
- * Returns undefined if any required variable is missing.
370
- */
371
- declare function readInfraConfigFromEnv(): ContainerInfraConfig | undefined;
372
-
373
228
  interface RecordingResult {
374
229
  /** Whether a response mismatch was detected between capture and replay. */
375
230
  responseMismatch: boolean;
@@ -394,39 +249,49 @@ interface RecordingResult {
394
249
  */
395
250
  declare function createRequestRecording(blobUrlOrData: string | BlobData, handlerPath: string, requestInfo: RequestInfo): Promise<RecordingResult>;
396
251
 
252
+ type SqlFunction$1 = (...args: any[]) => Promise<any[]>;
253
+ interface BackendRequest {
254
+ id: string;
255
+ blob_data: string;
256
+ handler_path: string;
257
+ commit_sha: string;
258
+ branch_name: string;
259
+ repository_url: string | null;
260
+ status: string;
261
+ recording_id: string | null;
262
+ error_message: string | null;
263
+ created_at: string;
264
+ updated_at: string;
265
+ }
397
266
  /**
398
- * Options for spawning a recording container from a blob URL.
399
- * This is the core building block — it knows nothing about request IDs or databases.
267
+ * Creates the `backend_requests` table. Call during schema initialization.
268
+ *
269
+ * Each package client stores captured request data in its own database
270
+ * using this table. The blob data (captured network calls, env reads, etc.)
271
+ * is stored directly in the `blob_data` column rather than in external
272
+ * blob storage.
400
273
  */
401
- interface SpawnRecordingContainerOptions {
402
- /** URL (or data: URI) of the captured request blob JSON. */
403
- blobUrl: string;
404
- /** Handler file path relative to the app root (e.g. "netlify/functions/generate-haiku"). */
274
+ declare function backendRequestsEnsureTable(sql: SqlFunction$1): Promise<void>;
275
+ declare function backendRequestsInsert(sql: SqlFunction$1, data: {
276
+ id?: string;
277
+ blobData: string;
405
278
  handlerPath: string;
406
- /** Git commit SHA to check out inside the container. */
407
279
  commitSha: string;
408
- /** Git branch to clone. */
409
280
  branchName: string;
410
- /** Git repository URL for the app. */
411
- repositoryUrl: string;
412
- /** Infrastructure credentials for Fly.io + Infisical. */
413
- infraConfig: ContainerInfraConfig;
414
- /** Optional webhook URL the container can POST log events to. */
415
- logWebhookUrl?: string;
416
- /** Optional callback for structured log entries. */
417
- onLog?: (level: "info" | "warn" | "error", message: string) => Promise<void>;
418
- }
281
+ repositoryUrl?: string | null;
282
+ }): Promise<string>;
283
+ declare function backendRequestsGet(sql: SqlFunction$1, id: string): Promise<BackendRequest | null>;
284
+ declare function backendRequestsGetBlobData(sql: SqlFunction$1, id: string): Promise<string | null>;
285
+ declare function backendRequestsList(sql: SqlFunction$1, filters?: {
286
+ status?: string;
287
+ limit?: number;
288
+ }): Promise<BackendRequest[]>;
289
+ declare function backendRequestsUpdateStatus(sql: SqlFunction$1, id: string, status: string, recordingId?: string, errorMessage?: string): Promise<void>;
419
290
  /**
420
- * Spawns a detached Fly.io container that:
421
- * 1. Clones the app repo at the correct branch
422
- * 2. Checks out the exact commit
423
- * 3. Runs `scripts/create-request-recording.ts` under replay-node
424
- * 4. Uploads the resulting recording
425
- * 5. Outputs the recording ID
426
- *
427
- * Returns the recording ID on success, or throws on failure.
291
+ * Convenience helper: creates `FinishRequestCallbacks` that store
292
+ * captured request data directly in the `backend_requests` table.
428
293
  */
429
- declare function spawnRecordingContainer(options: SpawnRecordingContainerOptions): Promise<string>;
294
+ declare function databaseCallbacks(sql: SqlFunction$1): FinishRequestCallbacks;
430
295
 
431
296
  type SqlFunction = (...args: any[]) => Promise<any[]>;
432
297
  /**
@@ -451,4 +316,4 @@ declare function databaseAuditDumpLogTable(sql: SqlFunction): Promise<Record<str
451
316
 
452
317
  declare function getCurrentRequestId(): string | null;
453
318
 
454
- export { type BlobData, type CapturedData, type ContainerInfraConfig, type CreateRecordingRequestHandlerOptions, type EnsureRecordingOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type HandlerResponse$1 as HandlerResponse, type NetlifyEvent, type NetlifyV2Request, type NetworkCall, type RecordingResult, type RequestContext, type RequestInfo, type SpawnRecordingContainerOptions, createRecordingRequestHandler, createRequestRecording, databaseAuditDumpLogTable, databaseAuditEnsureLogTable, databaseAuditMonitorTable, ensureRequestRecording, finishRequest, getCurrentRequestId, readInfraConfigFromEnv, redactBlobData, remoteCallbacks, spawnRecordingContainer, startRequest };
319
+ export { type BackendRequest, type BlobData, type CapturedData, type CreateRecordingRequestHandlerOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type HandlerResponse$1 as HandlerResponse, type NetlifyEvent, type NetlifyV2Request, type NetworkCall, type RecordingResult, type RequestContext, type RequestInfo, backendRequestsEnsureTable, backendRequestsGet, backendRequestsGetBlobData, backendRequestsInsert, backendRequestsList, backendRequestsUpdateStatus, createRecordingRequestHandler, createRequestRecording, databaseAuditDumpLogTable, databaseAuditEnsureLogTable, databaseAuditMonitorTable, databaseCallbacks, finishRequest, getCurrentRequestId, redactBlobData, startRequest };
package/dist/index.js CHANGED
@@ -490,7 +490,6 @@ function redactBlobData(blobData) {
490
490
 
491
491
  // src/finishRequest.ts
492
492
  var SLOW_THRESHOLD_MS = 2e3;
493
- var SLOW_STEP_THRESHOLD_MS = 1e3;
494
493
  async function finishRequest(requestContext, callbacks, response, options) {
495
494
  const finishStart = Date.now();
496
495
  requestContext.cleanup();
@@ -535,34 +534,20 @@ async function finishRequest(requestContext, callbacks, response, options) {
535
534
  };
536
535
  const blobData = redactBlobData(rawBlobData);
537
536
  const blobContent = JSON.stringify(blobData);
538
- const uploadStart = Date.now();
539
- const blobUrl = await callbacks.uploadBlob(blobContent);
540
- const uploadDuration = Date.now() - uploadStart;
541
- if (uploadDuration > SLOW_STEP_THRESHOLD_MS) {
542
- console.warn(
543
- `netlify-recorder: uploadBlob took ${uploadDuration}ms (handler: ${handlerPath})`
544
- );
545
- }
546
537
  const storeStart = Date.now();
547
- const storedRequestId = await callbacks.storeRequestData({
548
- blobUrl,
538
+ const storedRequestId = await callbacks.storeRequest({
539
+ blobData: blobContent,
549
540
  commitSha,
550
541
  branchName,
551
542
  repositoryUrl,
552
543
  handlerPath,
553
- requestId: options?.requestId,
554
- secret: options?.secret
544
+ requestId: options?.requestId
555
545
  });
556
546
  const storeDuration = Date.now() - storeStart;
557
- if (storeDuration > SLOW_STEP_THRESHOLD_MS) {
558
- console.warn(
559
- `netlify-recorder: storeRequestData took ${storeDuration}ms (handler: ${handlerPath})`
560
- );
561
- }
562
547
  const totalDuration = Date.now() - finishStart;
563
548
  if (totalDuration > SLOW_THRESHOLD_MS) {
564
549
  console.warn(
565
- `netlify-recorder: finishRequest took ${totalDuration}ms total (upload: ${uploadDuration}ms, store: ${storeDuration}ms, handler: ${handlerPath})`
550
+ `netlify-recorder: finishRequest took ${totalDuration}ms total (store: ${storeDuration}ms, handler: ${handlerPath})`
566
551
  );
567
552
  }
568
553
  return {
@@ -618,284 +603,6 @@ function createRecordingRequestHandler(handler, options) {
618
603
  };
619
604
  }
620
605
 
621
- // src/remoteCallbacks.ts
622
- function remoteCallbacks(serviceUrl) {
623
- const base = serviceUrl.replace(/\/+$/, "");
624
- let pendingBlobData;
625
- return {
626
- uploadBlob: async (data) => {
627
- pendingBlobData = data;
628
- return "__pending__";
629
- },
630
- storeRequestData: async (metadata) => {
631
- const blobData = pendingBlobData;
632
- pendingBlobData = void 0;
633
- if (!blobData) {
634
- throw new Error(
635
- "remoteCallbacks: uploadBlob must be called before storeRequestData"
636
- );
637
- }
638
- const res = await fetch(
639
- `${base}/api/store-request`,
640
- {
641
- method: "POST",
642
- headers: { "Content-Type": "application/json" },
643
- body: JSON.stringify({
644
- blobData,
645
- handlerPath: metadata.handlerPath,
646
- commitSha: metadata.commitSha,
647
- branchName: metadata.branchName,
648
- repositoryUrl: metadata.repositoryUrl,
649
- requestId: metadata.requestId,
650
- secret: metadata.secret
651
- })
652
- }
653
- );
654
- if (!res.ok) {
655
- const errBody = await res.text().catch(() => "(unreadable)");
656
- throw new Error(
657
- `Netlify Recorder store-request failed: ${res.status} ${errBody}`
658
- );
659
- }
660
- const result = await res.json();
661
- return result.requestId;
662
- }
663
- };
664
- }
665
-
666
- // src/spawnRecordingContainer.ts
667
- async function spawnRecordingContainer(options) {
668
- const {
669
- blobUrl,
670
- handlerPath,
671
- commitSha,
672
- branchName,
673
- repositoryUrl,
674
- infraConfig,
675
- logWebhookUrl,
676
- onLog
677
- } = options;
678
- const emit = async (level, message) => {
679
- if (onLog) {
680
- try {
681
- await onLog(level, message);
682
- } catch {
683
- }
684
- }
685
- };
686
- await emit("info", "Logging in to Infisical");
687
- const {
688
- infisicalLogin,
689
- startContainer,
690
- FileContainerRegistry,
691
- httpGet,
692
- httpOptsFor
693
- } = await import("@replayio/app-building");
694
- const infisicalToken = await infisicalLogin(
695
- infraConfig.infisicalClientId,
696
- infraConfig.infisicalClientSecret
697
- );
698
- const infisicalConfig = {
699
- token: infisicalToken,
700
- projectId: infraConfig.infisicalProjectId,
701
- environment: infraConfig.infisicalEnvironment
702
- };
703
- const registry = new FileContainerRegistry("/tmp/netlify-recorder-containers.json");
704
- const initialPrompt = [
705
- `IMPORTANT: Follow these steps EXACTLY. Run each command as shown. Print ALL output.`,
706
- `Do NOT explore the codebase, read AGENTS.md, or deviate from these steps.`,
707
- `Do NOT attempt to debug, fix, shim, or work around ANY errors. If a command fails,`,
708
- `print the full error output and move on to the next step. Errors during handler replay`,
709
- `(like "No more recorded network calls" or DB errors) are EXPECTED and harmless.`,
710
- ``,
711
- `=== Step 1: Install dependencies ===`,
712
- `cd /repo/apps/netlify-recorder && npm install 2>&1`,
713
- ``,
714
- `=== Step 2: Checkout the exact commit ===`,
715
- `git fetch origin ${commitSha} 2>&1 || git fetch --all 2>&1`,
716
- `git checkout ${commitSha} 2>&1`,
717
- ``,
718
- `=== Step 3: Verify recording script exists ===`,
719
- `ls -la /repo/apps/netlify-recorder/scripts/create-request-recording.ts`,
720
- `If the file does NOT exist, print "ERROR: create-request-recording.ts not found" and STOP.`,
721
- ``,
722
- `=== Step 4: Pre-compile for replay-node (Node v16) ===`,
723
- `replay-node is Node v16 \u2014 it cannot run TypeScript or use modern APIs directly.`,
724
- `You MUST compile everything with esbuild first. Run these commands exactly:`,
725
- ``,
726
- `# Install undici for web API polyfills (fetch, Headers, Response):`,
727
- `cd /repo/apps/netlify-recorder && npm install undici@5 2>&1`,
728
- ``,
729
- `# Create the polyfill loader:`,
730
- `cat > /tmp/web-polyfill.cjs << 'POLYFILL'`,
731
- `try {`,
732
- ` var u = require("undici");`,
733
- ` if (!globalThis.fetch) globalThis.fetch = u.fetch;`,
734
- ` if (!globalThis.Headers) globalThis.Headers = u.Headers;`,
735
- ` if (!globalThis.Response) globalThis.Response = u.Response;`,
736
- ` if (!globalThis.Request) globalThis.Request = u.Request;`,
737
- `} catch(e) { console.error("polyfill warning:", e.message); }`,
738
- `POLYFILL`,
739
- ``,
740
- `# Compile the recording script (bundles all local TS dependencies):`,
741
- `npx esbuild scripts/create-request-recording.ts \\`,
742
- ` --bundle --platform=node --target=node16 --format=cjs \\`,
743
- ` --outfile=/tmp/create-recording.cjs 2>&1`,
744
- ``,
745
- `# Compile the handler (bundles everything including node_modules):`,
746
- `npx esbuild ${handlerPath}.ts \\`,
747
- ` --bundle --platform=node --target=node16 --format=cjs \\`,
748
- ` --outfile=/tmp/handler.cjs 2>&1`,
749
- ``,
750
- `=== Step 5: Run under replay-node ===`,
751
- `This MUST use replay-node so the execution is recorded. Run exactly:`,
752
- ``,
753
- `cd /repo/apps/netlify-recorder && npx @replayio/node \\`,
754
- ` -r /tmp/web-polyfill.cjs /tmp/create-recording.cjs \\`,
755
- ` --blob-url '${blobUrl}' \\`,
756
- ` --handler-path '/tmp/handler.cjs' 2>&1`,
757
- ``,
758
- `The output will show captured data being replayed. Errors like "No more recorded`,
759
- `network calls" or "DATABASE_URL" errors are EXPECTED \u2014 they come from post-handler`,
760
- `DB operations that were not in the original blob. Do NOT try to fix these.`,
761
- ``,
762
- `=== Step 6: Upload the recording ===`,
763
- `exec-secrets RECORD_REPLAY_API_KEY -- npx replayio upload --all 2>&1`,
764
- ``,
765
- `Find the recording ID (UUID) in the upload output and print:`,
766
- ` recording: <recording-id>`,
767
- ``,
768
- `Then output <DONE>.`
769
- ].join("\n");
770
- await emit("info", "Starting detached container on Fly.io");
771
- const state = await startContainer(
772
- {
773
- infisical: infisicalConfig,
774
- registry,
775
- flyToken: infraConfig.flyToken,
776
- flyApp: infraConfig.flyApp,
777
- detached: true,
778
- initialPrompt,
779
- webhookUrl: logWebhookUrl
780
- },
781
- {
782
- repoUrl: repositoryUrl,
783
- cloneBranch: branchName
784
- }
785
- );
786
- await emit("info", `Container started: ${state.containerName} at ${state.baseUrl}`);
787
- const maxWaitMs = 10 * 60 * 1e3;
788
- const pollIntervalMs = 1e4;
789
- const deadline = Date.now() + maxWaitMs;
790
- let containerDone = false;
791
- while (Date.now() < deadline) {
792
- try {
793
- const status = await httpGet(`${state.baseUrl}/status`, httpOptsFor(state));
794
- if (status?.state === "stopped" || status?.state === "stopping") {
795
- containerDone = true;
796
- break;
797
- }
798
- } catch {
799
- containerDone = true;
800
- break;
801
- }
802
- await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
803
- }
804
- if (!containerDone) {
805
- await emit("warn", "Container did not finish within 10 minutes");
806
- }
807
- let recordingId = null;
808
- try {
809
- const logs = await httpGet(`${state.baseUrl}/logs?offset=0`, httpOptsFor(state));
810
- if (typeof logs === "string") {
811
- const match = logs.match(/recording[:\s]+([a-f0-9-]{36})/i);
812
- if (match?.[1]) {
813
- recordingId = match[1];
814
- }
815
- }
816
- } catch {
817
- await emit("warn", "Could not read container logs after exit");
818
- }
819
- if (!recordingId) {
820
- await emit("error", "Container completed but no recording ID was found in output");
821
- throw new Error("Recording creation failed: no recording ID returned from container");
822
- }
823
- await emit("info", `Container completed \u2014 recording ID: ${recordingId}`);
824
- return recordingId;
825
- }
826
-
827
- // src/ensureRequestRecording.ts
828
- async function ensureRequestRecording(requestId, options) {
829
- const { repositoryUrl, infraConfig, webhookUrl, lookupRequest, updateStatus, onLog } = options;
830
- const emit = async (level, message) => {
831
- if (onLog) {
832
- try {
833
- await onLog(level, message);
834
- } catch {
835
- }
836
- }
837
- };
838
- await updateStatus(requestId, "processing");
839
- try {
840
- if (!infraConfig) {
841
- const missing = getMissingInfraVars();
842
- throw new Error(
843
- `Container infrastructure not configured. Missing environment variables: ${missing.join(", ")}. These must be set on the Netlify site for recording creation to work.`
844
- );
845
- }
846
- const requestData = await lookupRequest(requestId);
847
- await emit("info", `Request data retrieved \u2014 handler: ${requestData.handlerPath}, branch: ${requestData.branchName}, commit: ${requestData.commitSha}`);
848
- const recordingId = await spawnRecordingContainer({
849
- blobUrl: requestData.blobUrl,
850
- handlerPath: requestData.handlerPath,
851
- commitSha: requestData.commitSha,
852
- branchName: requestData.branchName,
853
- repositoryUrl,
854
- infraConfig,
855
- logWebhookUrl: webhookUrl,
856
- onLog
857
- });
858
- await emit("info", `Recording created successfully: ${recordingId}`);
859
- await updateStatus(requestId, "recorded", recordingId);
860
- return recordingId;
861
- } catch (err) {
862
- const message = err instanceof Error ? err.message : String(err);
863
- await emit("error", `Recording creation failed: ${message}`);
864
- await updateStatus(requestId, "failed");
865
- throw err;
866
- }
867
- }
868
- function getMissingInfraVars() {
869
- const required = [
870
- "INFISICAL_CLIENT_ID",
871
- "INFISICAL_CLIENT_SECRET",
872
- "INFISICAL_PROJECT_ID",
873
- "INFISICAL_ENVIRONMENT",
874
- "FLY_API_TOKEN",
875
- "FLY_APP_NAME"
876
- ];
877
- return required.filter((name) => !process.env[name]);
878
- }
879
- function readInfraConfigFromEnv() {
880
- const clientId = process.env.INFISICAL_CLIENT_ID;
881
- const clientSecret = process.env.INFISICAL_CLIENT_SECRET;
882
- const projectId = process.env.INFISICAL_PROJECT_ID;
883
- const environment = process.env.INFISICAL_ENVIRONMENT;
884
- const flyToken = process.env.FLY_API_TOKEN;
885
- const flyApp = process.env.FLY_APP_NAME;
886
- if (!clientId || !clientSecret || !projectId || !environment || !flyToken || !flyApp) {
887
- return void 0;
888
- }
889
- return {
890
- infisicalClientId: clientId,
891
- infisicalClientSecret: clientSecret,
892
- infisicalProjectId: projectId,
893
- infisicalEnvironment: environment,
894
- flyToken,
895
- flyApp
896
- };
897
- }
898
-
899
606
  // src/createRequestRecording.ts
900
607
  async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
901
608
  let blobData;
@@ -1203,6 +910,131 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
1203
910
  return result;
1204
911
  }
1205
912
 
913
+ // src/backendRequests.ts
914
+ async function backendRequestsEnsureTable(sql) {
915
+ await sql`
916
+ CREATE TABLE IF NOT EXISTS backend_requests (
917
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
918
+ blob_data TEXT NOT NULL,
919
+ handler_path TEXT NOT NULL,
920
+ commit_sha TEXT NOT NULL,
921
+ branch_name TEXT NOT NULL DEFAULT 'main',
922
+ repository_url TEXT,
923
+ status TEXT NOT NULL DEFAULT 'captured'
924
+ CHECK (status IN ('captured', 'queued', 'processing', 'recorded', 'failed')),
925
+ recording_id TEXT,
926
+ error_message TEXT,
927
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
928
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
929
+ )
930
+ `;
931
+ await sql`
932
+ CREATE INDEX IF NOT EXISTS idx_backend_requests_status ON backend_requests (status)
933
+ `;
934
+ await sql`
935
+ CREATE INDEX IF NOT EXISTS idx_backend_requests_created_at ON backend_requests (created_at DESC)
936
+ `;
937
+ }
938
+ async function backendRequestsInsert(sql, data) {
939
+ if (data.id) {
940
+ await sql`
941
+ INSERT INTO backend_requests (id, blob_data, handler_path, commit_sha, branch_name, repository_url)
942
+ VALUES (
943
+ ${data.id}::uuid,
944
+ ${data.blobData},
945
+ ${data.handlerPath},
946
+ ${data.commitSha},
947
+ ${data.branchName},
948
+ ${data.repositoryUrl ?? null}
949
+ )
950
+ `;
951
+ return data.id;
952
+ }
953
+ const rows = await sql`
954
+ INSERT INTO backend_requests (blob_data, handler_path, commit_sha, branch_name, repository_url)
955
+ VALUES (
956
+ ${data.blobData},
957
+ ${data.handlerPath},
958
+ ${data.commitSha},
959
+ ${data.branchName},
960
+ ${data.repositoryUrl ?? null}
961
+ )
962
+ RETURNING id
963
+ `;
964
+ return rows[0]?.id ?? "";
965
+ }
966
+ async function backendRequestsGet(sql, id) {
967
+ const rows = await sql`
968
+ SELECT id, blob_data, handler_path, commit_sha, branch_name, repository_url,
969
+ status, recording_id, error_message, created_at, updated_at
970
+ FROM backend_requests WHERE id = ${id}
971
+ `;
972
+ return rows[0] ?? null;
973
+ }
974
+ async function backendRequestsGetBlobData(sql, id) {
975
+ const rows = await sql`
976
+ SELECT blob_data FROM backend_requests WHERE id = ${id}
977
+ `;
978
+ return rows[0]?.blob_data ?? null;
979
+ }
980
+ async function backendRequestsList(sql, filters) {
981
+ const limit = filters?.limit ?? 50;
982
+ if (filters?.status) {
983
+ const rows2 = await sql`
984
+ SELECT id, blob_data, handler_path, commit_sha, branch_name, repository_url,
985
+ status, recording_id, error_message, created_at, updated_at
986
+ FROM backend_requests
987
+ WHERE status = ${filters.status}
988
+ ORDER BY created_at DESC
989
+ LIMIT ${limit}
990
+ `;
991
+ return rows2;
992
+ }
993
+ const rows = await sql`
994
+ SELECT id, blob_data, handler_path, commit_sha, branch_name, repository_url,
995
+ status, recording_id, error_message, created_at, updated_at
996
+ FROM backend_requests
997
+ ORDER BY created_at DESC
998
+ LIMIT ${limit}
999
+ `;
1000
+ return rows;
1001
+ }
1002
+ async function backendRequestsUpdateStatus(sql, id, status, recordingId, errorMessage) {
1003
+ if (recordingId) {
1004
+ await sql`
1005
+ UPDATE backend_requests
1006
+ SET status = ${status}, recording_id = ${recordingId}, updated_at = NOW()
1007
+ WHERE id = ${id}
1008
+ `;
1009
+ } else if (errorMessage) {
1010
+ await sql`
1011
+ UPDATE backend_requests
1012
+ SET status = ${status}, error_message = ${errorMessage}, updated_at = NOW()
1013
+ WHERE id = ${id}
1014
+ `;
1015
+ } else {
1016
+ await sql`
1017
+ UPDATE backend_requests
1018
+ SET status = ${status}, updated_at = NOW()
1019
+ WHERE id = ${id}
1020
+ `;
1021
+ }
1022
+ }
1023
+ function databaseCallbacks(sql) {
1024
+ return {
1025
+ storeRequest: async (data) => {
1026
+ return backendRequestsInsert(sql, {
1027
+ id: data.requestId,
1028
+ blobData: data.blobData,
1029
+ handlerPath: data.handlerPath,
1030
+ commitSha: data.commitSha,
1031
+ branchName: data.branchName,
1032
+ repositoryUrl: data.repositoryUrl
1033
+ });
1034
+ }
1035
+ };
1036
+ }
1037
+
1206
1038
  // src/databaseAudit.ts
1207
1039
  async function databaseAuditEnsureLogTable(sql) {
1208
1040
  await sql`
@@ -1283,17 +1115,20 @@ async function databaseAuditDumpLogTable(sql) {
1283
1115
  return rows;
1284
1116
  }
1285
1117
  export {
1118
+ backendRequestsEnsureTable,
1119
+ backendRequestsGet,
1120
+ backendRequestsGetBlobData,
1121
+ backendRequestsInsert,
1122
+ backendRequestsList,
1123
+ backendRequestsUpdateStatus,
1286
1124
  createRecordingRequestHandler,
1287
1125
  createRequestRecording,
1288
1126
  databaseAuditDumpLogTable,
1289
1127
  databaseAuditEnsureLogTable,
1290
1128
  databaseAuditMonitorTable,
1291
- ensureRequestRecording,
1129
+ databaseCallbacks,
1292
1130
  finishRequest,
1293
1131
  getCurrentRequestId,
1294
- readInfraConfigFromEnv,
1295
1132
  redactBlobData,
1296
- remoteCallbacks,
1297
- spawnRecordingContainer,
1298
1133
  startRequest
1299
1134
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replayio-app-building/netlify-recorder",
3
- "version": "0.15.9",
3
+ "version": "0.16.0",
4
4
  "description": "Capture and replay Netlify function executions as Replay recordings",
5
5
  "type": "module",
6
6
  "exports": {