@replayio-app-building/netlify-recorder 0.6.0 → 0.8.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/dist/index.d.ts CHANGED
@@ -34,6 +34,9 @@ interface RequestContext {
34
34
  startTime: number;
35
35
  /** Restores original globals (fetch, process.env). Called automatically by finishRequest. */
36
36
  cleanup: () => void;
37
+ /** For v2 Request inputs, a promise that resolves to the request body text.
38
+ * finishRequest awaits this before building the blob so POST bodies are captured. */
39
+ _v2BodyPromise?: Promise<string | undefined>;
37
40
  }
38
41
  interface CapturedData {
39
42
  networkCalls: NetworkCall[];
@@ -54,6 +57,10 @@ interface EnvRead {
54
57
  value: string | undefined;
55
58
  timestamp: number;
56
59
  }
60
+ interface HandlerResponse {
61
+ statusCode: number;
62
+ body?: string;
63
+ }
57
64
  interface BlobData {
58
65
  requestInfo: RequestInfo;
59
66
  capturedData: CapturedData;
@@ -61,6 +68,8 @@ interface BlobData {
61
68
  commitSha: string;
62
69
  startTime: number;
63
70
  endTime: number;
71
+ /** The response returned to the client, used to detect replay mismatches. */
72
+ handlerResponse?: HandlerResponse;
64
73
  }
65
74
  interface FinishRequestCallbacks {
66
75
  /** Uploads serialized captured data and returns the blob URL. */
@@ -116,19 +125,20 @@ interface EnsureRecordingOptions {
116
125
 
117
126
  /**
118
127
  * Called at the beginning of a Netlify handler execution.
119
- * Accepts the raw Netlify event and extracts all request metadata internally.
120
- * Installs interceptors on globalThis.fetch and process.env to capture
121
- * outbound network calls and environment variable reads made by the handler.
122
- * Returns a request context used by finishRequest.
128
+ * Accepts either a Netlify Functions v1 event (HandlerEvent with httpMethod,
129
+ * path, etc.) or a v2 Web API Request. Extracts all request metadata
130
+ * internally, installs interceptors on globalThis.fetch and process.env to
131
+ * capture outbound network calls and environment variable reads, and returns
132
+ * a request context used by finishRequest.
123
133
  *
124
134
  * When running inside createRequestRecording (replay mode), the replay
125
135
  * interceptors are already installed. In that case we skip installing
126
136
  * capture interceptors to avoid overwriting the replay layer (which would
127
137
  * also crash on Node v16 where Headers/Response don't exist).
128
138
  */
129
- declare function startRequest(event: NetlifyEvent): RequestContext;
139
+ declare function startRequest(event: NetlifyEvent | /* v2 Request */ Record<string, unknown>): RequestContext;
130
140
 
131
- interface HandlerResponse {
141
+ interface FullHandlerResponse {
132
142
  statusCode: number;
133
143
  headers?: Record<string, string>;
134
144
  body?: string;
@@ -148,7 +158,7 @@ interface FinishRequestOptions {
148
158
  * uploads it as a JSON blob via the provided callback,
149
159
  * stores the request metadata, and sets the X-Replay-Request-Id header.
150
160
  */
151
- declare function finishRequest(requestContext: RequestContext, callbacks: FinishRequestCallbacks, response: HandlerResponse, options?: FinishRequestOptions): Promise<HandlerResponse>;
161
+ declare function finishRequest(requestContext: RequestContext, callbacks: FinishRequestCallbacks, response: FullHandlerResponse, options?: FinishRequestOptions): Promise<FullHandlerResponse>;
152
162
 
153
163
  /**
154
164
  * Creates `FinishRequestCallbacks` that send captured data to a remote
@@ -206,6 +216,20 @@ declare function ensureRequestRecording(requestId: string, options: EnsureRecord
206
216
  */
207
217
  declare function readInfraConfigFromEnv(): ContainerInfraConfig | undefined;
208
218
 
219
+ interface RecordingResult {
220
+ /** Whether a response mismatch was detected between capture and replay. */
221
+ responseMismatch: boolean;
222
+ /** Details about the mismatch, if any. */
223
+ mismatchDetails?: string;
224
+ /** The response produced during replay. */
225
+ replayResponse?: HandlerResponse;
226
+ /** The original response captured in the blob. */
227
+ capturedResponse?: HandlerResponse;
228
+ /** Whether some recorded network calls were not consumed during replay. */
229
+ unconsumedNetworkCalls: boolean;
230
+ /** Details about unconsumed network calls, if any. */
231
+ unconsumedNetworkDetails?: string;
232
+ }
209
233
  /**
210
234
  * Called from within a container (running under replay-node) to create a Replay recording.
211
235
  * Installs replay-mode interceptors that return pre-recorded responses, then executes
@@ -214,7 +238,7 @@ declare function readInfraConfigFromEnv(): ContainerInfraConfig | undefined;
214
238
  * Accepts either a blob URL (fetched at runtime) or pre-parsed BlobData (avoids needing
215
239
  * globalThis.fetch, which is missing in replay-node's Node v16 environment).
216
240
  */
217
- declare function createRequestRecording(blobUrlOrData: string | BlobData, handlerPath: string, requestInfo: RequestInfo): Promise<void>;
241
+ declare function createRequestRecording(blobUrlOrData: string | BlobData, handlerPath: string, requestInfo: RequestInfo): Promise<RecordingResult>;
218
242
 
219
243
  /**
220
244
  * Options for spawning a recording container from a blob URL.
@@ -252,4 +276,4 @@ interface SpawnRecordingContainerOptions {
252
276
  */
253
277
  declare function spawnRecordingContainer(options: SpawnRecordingContainerOptions): Promise<string>;
254
278
 
255
- export { type BlobData, type CapturedData, type ContainerInfraConfig, type EnsureRecordingOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type NetlifyEvent, type NetworkCall, type RequestContext, type RequestInfo, type SpawnRecordingContainerOptions, createRequestRecording, ensureRequestRecording, finishRequest, readInfraConfigFromEnv, redactBlobData, remoteCallbacks, spawnRecordingContainer, startRequest };
279
+ export { type BlobData, type CapturedData, type ContainerInfraConfig, type EnsureRecordingOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, type HandlerResponse, type NetlifyEvent, type NetworkCall, type RecordingResult, type RequestContext, type RequestInfo, type SpawnRecordingContainerOptions, createRequestRecording, ensureRequestRecording, finishRequest, readInfraConfigFromEnv, redactBlobData, remoteCallbacks, spawnRecordingContainer, startRequest };
package/dist/index.js CHANGED
@@ -131,14 +131,71 @@ function installEnvironmentInterceptor(mode, reads) {
131
131
  }
132
132
 
133
133
  // src/startRequest.ts
134
- function startRequest(event) {
134
+ function isWebApiRequest(event) {
135
+ if (!event || typeof event !== "object") return false;
136
+ const obj = event;
137
+ return typeof obj.httpMethod === "undefined" && typeof obj.method === "string" && typeof obj.url === "string";
138
+ }
139
+ function extractFromWebRequest(req) {
140
+ const r = req;
141
+ const headers = {};
142
+ if (r.headers && typeof r.headers.forEach === "function") {
143
+ r.headers.forEach((value, key) => {
144
+ headers[key] = value;
145
+ });
146
+ }
147
+ let pathname = "/";
148
+ let rawQuery = "";
149
+ let queryStringParameters = null;
150
+ try {
151
+ const parsed = new URL(r.url);
152
+ pathname = parsed.pathname;
153
+ rawQuery = parsed.search ? parsed.search.slice(1) : "";
154
+ if (parsed.searchParams) {
155
+ const params = {};
156
+ parsed.searchParams.forEach((v, k) => {
157
+ params[k] = v;
158
+ });
159
+ if (Object.keys(params).length > 0) queryStringParameters = params;
160
+ }
161
+ } catch {
162
+ pathname = r.url;
163
+ }
164
+ const requestInfo = {
165
+ method: r.method,
166
+ url: pathname,
167
+ headers,
168
+ body: void 0,
169
+ // filled asynchronously below
170
+ queryStringParameters,
171
+ multiValueQueryStringParameters: null,
172
+ rawUrl: r.url,
173
+ rawQuery,
174
+ isBase64Encoded: false
175
+ };
176
+ let bodyPromise = Promise.resolve(void 0);
177
+ if (r.body !== null && r.body !== void 0 && typeof r.clone === "function") {
178
+ try {
179
+ const clone = r.clone();
180
+ if (typeof clone.text === "function") {
181
+ bodyPromise = clone.text().then(
182
+ (text) => text || void 0,
183
+ () => void 0
184
+ );
185
+ }
186
+ } catch {
187
+ }
188
+ }
189
+ return { requestInfo, bodyPromise };
190
+ }
191
+ function extractFromV1Event(event) {
135
192
  const headers = {};
136
193
  for (const [key, value] of Object.entries(event.headers)) {
137
194
  if (value !== void 0) {
138
195
  headers[key] = value;
139
196
  }
140
197
  }
141
- const requestInfo = {
198
+ return {
142
199
  method: event.httpMethod,
143
200
  url: event.path,
144
201
  headers,
@@ -149,6 +206,17 @@ function startRequest(event) {
149
206
  rawQuery: event.rawQuery,
150
207
  isBase64Encoded: event.isBase64Encoded
151
208
  };
209
+ }
210
+ function startRequest(event) {
211
+ let requestInfo;
212
+ let bodyPromise;
213
+ if (isWebApiRequest(event)) {
214
+ const extracted = extractFromWebRequest(event);
215
+ requestInfo = extracted.requestInfo;
216
+ bodyPromise = extracted.bodyPromise;
217
+ } else {
218
+ requestInfo = extractFromV1Event(event);
219
+ }
152
220
  const capturedData = { networkCalls: [], envReads: [] };
153
221
  const isReplay = globalThis.__REPLAY_RECORDING_MODE__ === true;
154
222
  let cleanup;
@@ -167,7 +235,8 @@ function startRequest(event) {
167
235
  requestInfo,
168
236
  capturedData,
169
237
  startTime: Date.now(),
170
- cleanup
238
+ cleanup,
239
+ _v2BodyPromise: bodyPromise
171
240
  };
172
241
  }
173
242
 
@@ -318,12 +387,25 @@ async function finishRequest(requestContext, callbacks, response, options) {
318
387
  const commitSha = rawCommitSha;
319
388
  const branchName = rawBranchName;
320
389
  const repositoryUrl = rawRepositoryUrl;
390
+ if (requestContext._v2BodyPromise && !requestContext.requestInfo.body) {
391
+ try {
392
+ const bodyText = await requestContext._v2BodyPromise;
393
+ if (bodyText) {
394
+ requestContext.requestInfo.body = bodyText;
395
+ }
396
+ } catch {
397
+ }
398
+ }
321
399
  const rawBlobData = {
322
400
  requestInfo: requestContext.requestInfo,
323
401
  capturedData: requestContext.capturedData,
324
402
  commitSha,
325
403
  startTime: requestContext.startTime,
326
- endTime: Date.now()
404
+ endTime: Date.now(),
405
+ handlerResponse: {
406
+ statusCode: response.statusCode,
407
+ body: response.body
408
+ }
327
409
  };
328
410
  const blobData = redactBlobData(rawBlobData);
329
411
  const blobContent = JSON.stringify(blobData);
@@ -675,34 +757,233 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
675
757
  };
676
758
  g.File = FileShim;
677
759
  }
760
+ if (typeof g.Headers === "undefined") {
761
+ const HeadersShim = class Headers {
762
+ _map;
763
+ constructor(init) {
764
+ this._map = /* @__PURE__ */ new Map();
765
+ if (init) {
766
+ const entries = Array.isArray(init) ? init : Object.entries(init);
767
+ for (const [k, v] of entries) this._map.set(k.toLowerCase(), String(v));
768
+ }
769
+ }
770
+ get(name) {
771
+ return this._map.get(name.toLowerCase()) ?? null;
772
+ }
773
+ has(name) {
774
+ return this._map.has(name.toLowerCase());
775
+ }
776
+ set(name, value) {
777
+ this._map.set(name.toLowerCase(), String(value));
778
+ }
779
+ delete(name) {
780
+ this._map.delete(name.toLowerCase());
781
+ }
782
+ forEach(cb) {
783
+ this._map.forEach((v, k) => cb(v, k, this));
784
+ }
785
+ entries() {
786
+ return this._map.entries();
787
+ }
788
+ keys() {
789
+ return this._map.keys();
790
+ }
791
+ values() {
792
+ return this._map.values();
793
+ }
794
+ [Symbol.iterator]() {
795
+ return this._map.entries();
796
+ }
797
+ };
798
+ g.Headers = HeadersShim;
799
+ }
800
+ if (typeof g.Request === "undefined") {
801
+ const H = g.Headers;
802
+ const RequestShim = class Request {
803
+ method;
804
+ url;
805
+ headers;
806
+ _body;
807
+ constructor(input, init) {
808
+ this.url = input;
809
+ this.method = String(init?.method ?? "GET");
810
+ this.headers = new H(init?.headers);
811
+ this._body = init?.body ?? null;
812
+ }
813
+ async text() {
814
+ return this._body ?? "";
815
+ }
816
+ async json() {
817
+ return JSON.parse(this._body ?? "null");
818
+ }
819
+ clone() {
820
+ return new RequestShim(this.url, {
821
+ method: this.method,
822
+ headers: Object.fromEntries(this.headers.entries()),
823
+ body: this._body ?? void 0
824
+ });
825
+ }
826
+ };
827
+ g.Request = RequestShim;
828
+ }
829
+ if (typeof g.Response === "undefined") {
830
+ const H = g.Headers;
831
+ const ResponseShim = class Response {
832
+ status;
833
+ statusText;
834
+ headers;
835
+ ok;
836
+ _body;
837
+ constructor(body, init) {
838
+ this._body = body ?? null;
839
+ this.status = init?.status ?? 200;
840
+ this.statusText = init?.statusText ?? "";
841
+ this.headers = new H(init?.headers);
842
+ this.ok = this.status >= 200 && this.status < 300;
843
+ }
844
+ async text() {
845
+ return this._body ?? "";
846
+ }
847
+ async json() {
848
+ return JSON.parse(this._body ?? "null");
849
+ }
850
+ clone() {
851
+ return new ResponseShim(this._body, {
852
+ status: this.status,
853
+ statusText: this.statusText,
854
+ headers: Object.fromEntries(this.headers.entries())
855
+ });
856
+ }
857
+ };
858
+ g.Response = ResponseShim;
859
+ }
860
+ const result = { responseMismatch: false, unconsumedNetworkCalls: false };
678
861
  try {
679
862
  const handlerModule = await import(handlerPath);
680
- await handlerModule.handler({
681
- httpMethod: requestInfo.method,
682
- path: requestInfo.url,
683
- headers: requestInfo.headers,
684
- body: requestInfo.body ?? null,
685
- queryStringParameters: requestInfo.queryStringParameters ?? null,
686
- multiValueQueryStringParameters: requestInfo.multiValueQueryStringParameters ?? null,
687
- rawUrl: requestInfo.rawUrl ?? requestInfo.url,
688
- rawQuery: requestInfo.rawQuery ?? "",
689
- isBase64Encoded: requestInfo.isBase64Encoded ?? false
690
- });
863
+ let handler;
864
+ let isV2 = false;
865
+ let current = handlerModule;
866
+ for (let depth = 0; depth < 6 && current && typeof current === "object"; depth++) {
867
+ const obj = current;
868
+ if (typeof obj.handler === "function") {
869
+ handler = obj.handler;
870
+ isV2 = false;
871
+ break;
872
+ }
873
+ if (typeof obj.default === "function") {
874
+ handler = obj.default;
875
+ isV2 = true;
876
+ break;
877
+ }
878
+ if (obj.default && typeof obj.default === "object") {
879
+ current = obj.default;
880
+ continue;
881
+ }
882
+ break;
883
+ }
884
+ if (!handler && typeof handlerModule === "function") {
885
+ handler = handlerModule;
886
+ isV2 = true;
887
+ }
888
+ if (!handler) {
889
+ throw new Error(
890
+ `Could not resolve handler from ${handlerPath}. Module exports: [${Object.keys(handlerModule).join(", ")}]`
891
+ );
892
+ }
893
+ let rawResult;
894
+ if (isV2) {
895
+ const url = requestInfo.rawUrl ?? `https://localhost${requestInfo.url}`;
896
+ const reqInit = {
897
+ method: requestInfo.method,
898
+ headers: requestInfo.headers
899
+ };
900
+ if (requestInfo.body && requestInfo.method !== "GET" && requestInfo.method !== "HEAD") {
901
+ reqInit.body = requestInfo.body;
902
+ }
903
+ const RequestCtor = globalThis.Request;
904
+ if (!RequestCtor) {
905
+ throw new Error(
906
+ "Handler uses Netlify Functions v2 signature but Request is not available in this environment. Ensure undici or a Request polyfill is installed."
907
+ );
908
+ }
909
+ const req = new RequestCtor(url, reqInit);
910
+ const context = { geo: {}, ip: "127.0.0.1", requestId: "replay", server: { region: "local" } };
911
+ const response = await handler(req, context);
912
+ if (response && typeof response === "object" && typeof response.status === "number" && typeof response.text === "function") {
913
+ const res = response;
914
+ rawResult = {
915
+ statusCode: res.status,
916
+ body: await res.text()
917
+ };
918
+ } else {
919
+ rawResult = response;
920
+ }
921
+ } else {
922
+ rawResult = await handler({
923
+ httpMethod: requestInfo.method,
924
+ path: requestInfo.url,
925
+ headers: requestInfo.headers,
926
+ body: requestInfo.body ?? null,
927
+ queryStringParameters: requestInfo.queryStringParameters ?? null,
928
+ multiValueQueryStringParameters: requestInfo.multiValueQueryStringParameters ?? null,
929
+ rawUrl: requestInfo.rawUrl ?? requestInfo.url,
930
+ rawQuery: requestInfo.rawQuery ?? "",
931
+ isBase64Encoded: requestInfo.isBase64Encoded ?? false
932
+ });
933
+ }
934
+ if (blobData.handlerResponse) {
935
+ const replayRes = rawResult;
936
+ const replayResponse = {
937
+ statusCode: replayRes?.statusCode ?? 0,
938
+ body: replayRes?.body
939
+ };
940
+ result.replayResponse = replayResponse;
941
+ result.capturedResponse = blobData.handlerResponse;
942
+ const statusMismatch = replayResponse.statusCode !== blobData.handlerResponse.statusCode;
943
+ const bodyMismatch = replayResponse.body !== blobData.handlerResponse.body;
944
+ if (statusMismatch || bodyMismatch) {
945
+ result.responseMismatch = true;
946
+ const details = [];
947
+ if (statusMismatch) {
948
+ details.push(
949
+ `Status code: captured=${blobData.handlerResponse.statusCode}, replay=${replayResponse.statusCode}`
950
+ );
951
+ }
952
+ if (bodyMismatch) {
953
+ const capturedPreview = (blobData.handlerResponse.body ?? "").slice(0, 200);
954
+ const replayPreview = (replayResponse.body ?? "").slice(0, 200);
955
+ details.push(
956
+ `Body differs: captured=${JSON.stringify(capturedPreview)}, replay=${JSON.stringify(replayPreview)}`
957
+ );
958
+ }
959
+ result.mismatchDetails = details.join("; ");
960
+ console.error(`RESPONSE_MISMATCH: The replay response differs from the captured response.`);
961
+ console.error(` ${result.mismatchDetails}`);
962
+ } else {
963
+ console.log(` [response-check] Replay response matches captured response.`);
964
+ }
965
+ }
691
966
  } finally {
692
967
  const consumed = networkHandle.consumedCount();
693
968
  const total = networkHandle.totalCount();
694
969
  if (consumed < total) {
970
+ result.unconsumedNetworkCalls = true;
971
+ const details = [
972
+ `Consumed ${consumed} of ${total} calls. ${total - consumed} call(s) were never replayed.`
973
+ ];
695
974
  console.error(
696
975
  `ERROR: Not all recorded network calls were consumed during replay. Consumed ${consumed} of ${total} calls. ${total - consumed} call(s) were never replayed.`
697
976
  );
698
977
  for (let i = consumed; i < total; i++) {
699
978
  const call = blobData.capturedData.networkCalls[i];
700
979
  if (call) {
980
+ details.push(`Unconsumed: ${call.method} ${call.url} => ${call.responseStatus}`);
701
981
  console.error(
702
982
  ` Unconsumed call ${i + 1}/${total}: ${call.method} ${call.url} => ${call.responseStatus}`
703
983
  );
704
984
  }
705
985
  }
986
+ result.unconsumedNetworkDetails = details.join("; ");
706
987
  } else {
707
988
  console.log(` [network-replay] All ${total} recorded network call(s) were consumed.`);
708
989
  }
@@ -710,6 +991,7 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
710
991
  networkHandle.restore();
711
992
  envHandle.restore();
712
993
  }
994
+ return result;
713
995
  }
714
996
  export {
715
997
  createRequestRecording,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replayio-app-building/netlify-recorder",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Capture and replay Netlify function executions as Replay recordings",
5
5
  "type": "module",
6
6
  "exports": {