@replayio-app-building/netlify-recorder 0.44.0 → 0.46.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
@@ -169,6 +169,21 @@ const status = await fetch(
169
169
  | `not_found` | 404 | Request ID not found in `backend_requests` |
170
170
  | `error` | 4xx/5xx | Validation error, auth failure, or recording failure |
171
171
 
172
+ #### Warm-start replay (preceding requests)
173
+
174
+ Netlify Functions run on AWS Lambda, where module-level state persists across invocations on the same container instance (warm starts). For example, a module-level `Map` used as a cache will retain entries across requests handled by the same container. This means a recording of a warm-start request may behave differently than the original if module-level state is empty.
175
+
176
+ The recorder handles this automatically. `createRecordingRequestHandler` maintains a module-level `originalRequestId` that captures the ID of the first request handled by each module instance. Every subsequent request on the same instance includes this reference in its blob data, linking all requests from the same warm-start chain.
177
+
178
+ When a recording is triggered for a request that has an `originalRequestId`, the recording service:
179
+
180
+ 1. Looks up all preceding requests from the same module instance (same `original_request_id`, earlier `created_at`)
181
+ 2. Fetches the blob data for each preceding request
182
+ 3. Replays the preceding requests in order on the handler module before executing the target request — each preceding request runs with its own replay interceptors so recorded network responses are served without making real calls
183
+ 4. Executes the target request, now with module-level state populated exactly as it was in production
184
+
185
+ This is fully automatic — no configuration or code changes are needed beyond wrapping your handlers with `createRecordingRequestHandler`. The `original_request_id` column is stored in the `backend_requests` table and the preceding blob URLs are resolved server-side.
186
+
172
187
  ### 5. Create recordings programmatically
173
188
 
174
189
  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"`.
package/dist/index.d.ts CHANGED
@@ -121,6 +121,8 @@ interface FinishRequestCallbacks {
121
121
  handlerPath: string;
122
122
  /** Pre-generated request ID. Use as the row ID when provided. */
123
123
  requestId?: string;
124
+ /** ID of the first request handled by this module instance (warm-start linkage). */
125
+ originalRequestId?: string;
124
126
  }) => Promise<string>;
125
127
  }
126
128
 
@@ -250,8 +252,13 @@ interface RecordingResult {
250
252
  *
251
253
  * Accepts either a blob URL (fetched at runtime) or pre-parsed BlobData (avoids needing
252
254
  * globalThis.fetch, which is missing in replay-node's Node v16 environment).
255
+ *
256
+ * When `precedingBlobs` is provided, the handler is executed once for each preceding
257
+ * blob (in order) before the target request. This replays prior requests on the same
258
+ * module instance to populate module-level state (e.g. in-memory caches) that would
259
+ * have existed on a warm Lambda container in production.
253
260
  */
254
- declare function createRequestRecording(blobUrlOrData: string | BlobData, handlerPath: string, requestInfo: RequestInfo): Promise<RecordingResult>;
261
+ declare function createRequestRecording(blobUrlOrData: string | BlobData, handlerPath: string, requestInfo: RequestInfo, precedingBlobs?: BlobData[]): Promise<RecordingResult>;
255
262
 
256
263
  type SqlFunction$2 = (...args: any[]) => Promise<any[]>;
257
264
  interface BackendRequest {
@@ -261,6 +268,7 @@ interface BackendRequest {
261
268
  commit_sha: string;
262
269
  branch_name: string;
263
270
  repository_url: string | null;
271
+ original_request_id: string | null;
264
272
  status: string;
265
273
  recording_id: string | null;
266
274
  error_message: string | null;
@@ -282,6 +290,7 @@ declare function backendRequestsInsert(sql: SqlFunction$2, data: {
282
290
  commitSha: string;
283
291
  branchName: string;
284
292
  repositoryUrl?: string | null;
293
+ originalRequestId?: string | null;
285
294
  }): Promise<string>;
286
295
  declare function backendRequestsGet(sql: SqlFunction$2, id: string): Promise<BackendRequest | null>;
287
296
  declare function backendRequestsGetBlobUrl(sql: SqlFunction$2, id: string): Promise<string | null>;
package/dist/index.js CHANGED
@@ -742,7 +742,8 @@ async function finishRequest(requestContext, callbacks, response, options) {
742
742
  branchName,
743
743
  repositoryUrl,
744
744
  handlerPath,
745
- requestId: options?.requestId
745
+ requestId: options?.requestId,
746
+ originalRequestId: options?.originalRequestId
746
747
  });
747
748
  const storeDuration = Date.now() - storeStart;
748
749
  const totalDuration = Date.now() - finishStart;
@@ -859,7 +860,7 @@ function createRecordingRequestHandler(handler, options) {
859
860
  }
860
861
 
861
862
  // src/createRequestRecording.ts
862
- async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
863
+ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo, precedingBlobs) {
863
864
  let blobData;
864
865
  if (typeof blobUrlOrData === "string") {
865
866
  const response = await fetch(blobUrlOrData);
@@ -1032,56 +1033,98 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
1032
1033
  g.Response = ResponseShim;
1033
1034
  }
1034
1035
  const result = { responseMismatch: false, unconsumedNetworkCalls: false };
1035
- try {
1036
- const handlerModule = await import(handlerPath);
1037
- let handler;
1038
- let isV2 = false;
1039
- let current = handlerModule;
1040
- for (let depth = 0; depth < 6 && current && typeof current === "object"; depth++) {
1041
- const obj = current;
1042
- if (typeof obj.handler === "function") {
1043
- handler = obj.handler;
1044
- isV2 = false;
1045
- break;
1046
- }
1047
- if (typeof obj.default === "function") {
1048
- handler = obj.default;
1036
+ const handlerModule = await import(handlerPath);
1037
+ let handler;
1038
+ let isV2 = false;
1039
+ let current = handlerModule;
1040
+ for (let depth = 0; depth < 6 && current && typeof current === "object"; depth++) {
1041
+ const obj = current;
1042
+ if (typeof obj.handler === "function") {
1043
+ handler = obj.handler;
1044
+ isV2 = false;
1045
+ break;
1046
+ }
1047
+ if (typeof obj.default === "function") {
1048
+ handler = obj.default;
1049
+ isV2 = true;
1050
+ break;
1051
+ }
1052
+ if (obj.default && typeof obj.default === "object") {
1053
+ current = obj.default;
1054
+ continue;
1055
+ }
1056
+ break;
1057
+ }
1058
+ if (!handler && typeof handlerModule === "function") {
1059
+ handler = handlerModule;
1060
+ isV2 = true;
1061
+ }
1062
+ if (!handler && requestInfo.method) {
1063
+ const httpMethodNames = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
1064
+ let target = handlerModule;
1065
+ for (let depth = 0; depth < 6 && target && typeof target === "object"; depth++) {
1066
+ const obj = target;
1067
+ const method = requestInfo.method.toUpperCase();
1068
+ if (httpMethodNames.includes(method) && typeof obj[method] === "function") {
1069
+ handler = obj[method];
1049
1070
  isV2 = true;
1050
1071
  break;
1051
1072
  }
1052
1073
  if (obj.default && typeof obj.default === "object") {
1053
- current = obj.default;
1074
+ target = obj.default;
1054
1075
  continue;
1055
1076
  }
1056
1077
  break;
1057
1078
  }
1058
- if (!handler && typeof handlerModule === "function") {
1059
- handler = handlerModule;
1060
- isV2 = true;
1061
- }
1062
- if (!handler && requestInfo.method) {
1063
- const httpMethodNames = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
1064
- let target = handlerModule;
1065
- for (let depth = 0; depth < 6 && target && typeof target === "object"; depth++) {
1066
- const obj = target;
1067
- const method = requestInfo.method.toUpperCase();
1068
- if (httpMethodNames.includes(method) && typeof obj[method] === "function") {
1069
- handler = obj[method];
1070
- isV2 = true;
1071
- break;
1072
- }
1073
- if (obj.default && typeof obj.default === "object") {
1074
- target = obj.default;
1075
- continue;
1079
+ }
1080
+ if (!handler) {
1081
+ throw new Error(
1082
+ `Could not resolve handler from ${handlerPath}. Module exports: [${Object.keys(handlerModule).join(", ")}]`
1083
+ );
1084
+ }
1085
+ if (precedingBlobs && precedingBlobs.length > 0) {
1086
+ console.log(` [warm-up] Replaying ${precedingBlobs.length} preceding request(s) to populate module-level state\u2026`);
1087
+ for (let i = 0; i < precedingBlobs.length; i++) {
1088
+ const pb = precedingBlobs[i];
1089
+ console.log(` [warm-up ${i + 1}/${precedingBlobs.length}] ${pb.requestInfo.method} ${pb.requestInfo.url}`);
1090
+ const netH = installNetworkInterceptor("replay", pb.capturedData.networkCalls);
1091
+ const envH = installEnvironmentInterceptor("replay", pb.capturedData.envReads);
1092
+ try {
1093
+ if (isV2) {
1094
+ const url = pb.requestInfo.rawUrl ?? `https://localhost${pb.requestInfo.url}`;
1095
+ const reqInit = { method: pb.requestInfo.method, headers: pb.requestInfo.headers };
1096
+ if (pb.requestInfo.body && pb.requestInfo.method !== "GET" && pb.requestInfo.method !== "HEAD") {
1097
+ reqInit.body = pb.requestInfo.body;
1098
+ }
1099
+ const RequestCtor = g.Request;
1100
+ if (RequestCtor) {
1101
+ const req = new RequestCtor(url, reqInit);
1102
+ const ctx = { geo: {}, ip: "127.0.0.1", requestId: "replay-warmup", server: { region: "local" } };
1103
+ g.__REPLAY_REQUEST_COOKIES__ = Object.keys(pb.requestInfo.headers).find((k) => k.toLowerCase() === "cookie") ? pb.requestInfo.headers[Object.keys(pb.requestInfo.headers).find((k) => k.toLowerCase() === "cookie")] : "";
1104
+ g.__REPLAY_REQUEST_HEADERS__ = pb.requestInfo.headers;
1105
+ await handler(req, ctx);
1106
+ }
1107
+ } else {
1108
+ await handler({
1109
+ httpMethod: pb.requestInfo.method,
1110
+ path: pb.requestInfo.url,
1111
+ headers: pb.requestInfo.headers,
1112
+ body: pb.requestInfo.body ?? null,
1113
+ queryStringParameters: pb.requestInfo.queryStringParameters ?? null,
1114
+ multiValueQueryStringParameters: pb.requestInfo.multiValueQueryStringParameters ?? null,
1115
+ rawUrl: pb.requestInfo.rawUrl ?? pb.requestInfo.url,
1116
+ rawQuery: pb.requestInfo.rawQuery ?? "",
1117
+ isBase64Encoded: pb.requestInfo.isBase64Encoded ?? false
1118
+ });
1076
1119
  }
1077
- break;
1120
+ } catch {
1078
1121
  }
1122
+ netH.restore();
1123
+ envH.restore();
1079
1124
  }
1080
- if (!handler) {
1081
- throw new Error(
1082
- `Could not resolve handler from ${handlerPath}. Module exports: [${Object.keys(handlerModule).join(", ")}]`
1083
- );
1084
- }
1125
+ console.log(` [warm-up] Done.`);
1126
+ }
1127
+ try {
1085
1128
  let rawResult;
1086
1129
  try {
1087
1130
  if (isV2) {
@@ -1225,30 +1268,38 @@ async function backendRequestsEnsureTable(sql) {
1225
1268
  await sql`
1226
1269
  CREATE INDEX IF NOT EXISTS idx_backend_requests_created_at ON backend_requests (created_at DESC)
1227
1270
  `;
1271
+ await sql`
1272
+ ALTER TABLE backend_requests ADD COLUMN IF NOT EXISTS original_request_id TEXT
1273
+ `;
1274
+ await sql`
1275
+ CREATE INDEX IF NOT EXISTS idx_backend_requests_original_request_id ON backend_requests (original_request_id)
1276
+ `;
1228
1277
  }
1229
1278
  async function backendRequestsInsert(sql, data) {
1230
1279
  if (data.id) {
1231
1280
  await sql`
1232
- INSERT INTO backend_requests (id, blob_data_url, handler_path, commit_sha, branch_name, repository_url)
1281
+ INSERT INTO backend_requests (id, blob_data_url, handler_path, commit_sha, branch_name, repository_url, original_request_id)
1233
1282
  VALUES (
1234
1283
  ${data.id}::uuid,
1235
1284
  ${data.blobDataUrl},
1236
1285
  ${data.handlerPath},
1237
1286
  ${data.commitSha},
1238
1287
  ${data.branchName},
1239
- ${data.repositoryUrl ?? null}
1288
+ ${data.repositoryUrl ?? null},
1289
+ ${data.originalRequestId ?? null}
1240
1290
  )
1241
1291
  `;
1242
1292
  return data.id;
1243
1293
  }
1244
1294
  const rows = await sql`
1245
- INSERT INTO backend_requests (blob_data_url, handler_path, commit_sha, branch_name, repository_url)
1295
+ INSERT INTO backend_requests (blob_data_url, handler_path, commit_sha, branch_name, repository_url, original_request_id)
1246
1296
  VALUES (
1247
1297
  ${data.blobDataUrl},
1248
1298
  ${data.handlerPath},
1249
1299
  ${data.commitSha},
1250
1300
  ${data.branchName},
1251
- ${data.repositoryUrl ?? null}
1301
+ ${data.repositoryUrl ?? null},
1302
+ ${data.originalRequestId ?? null}
1252
1303
  )
1253
1304
  RETURNING id
1254
1305
  `;
@@ -1339,7 +1390,8 @@ function databaseCallbacks(sql) {
1339
1390
  handlerPath: data.handlerPath,
1340
1391
  commitSha: data.commitSha,
1341
1392
  branchName: data.branchName,
1342
- repositoryUrl: data.repositoryUrl
1393
+ repositoryUrl: data.repositoryUrl,
1394
+ originalRequestId: data.originalRequestId
1343
1395
  });
1344
1396
  }
1345
1397
  };
@@ -1357,7 +1409,8 @@ function remoteCallbacks(recorderUrl) {
1357
1409
  branchName: data.branchName,
1358
1410
  repositoryUrl: data.repositoryUrl,
1359
1411
  handlerPath: data.handlerPath,
1360
- requestId: data.requestId
1412
+ requestId: data.requestId,
1413
+ originalRequestId: data.originalRequestId
1361
1414
  })
1362
1415
  });
1363
1416
  if (!res.ok) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replayio-app-building/netlify-recorder",
3
- "version": "0.44.0",
3
+ "version": "0.46.0",
4
4
  "description": "Capture and replay Netlify function executions as Replay recordings",
5
5
  "type": "module",
6
6
  "exports": {