@replayio-app-building/netlify-recorder 0.5.0 → 0.7.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 +5 -18
- package/dist/index.d.ts +57 -8
- package/dist/index.js +342 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -59,12 +59,7 @@ import type { Handler } from "@netlify/functions";
|
|
|
59
59
|
const RECORDER_URL = "https://netlify-recorder-bm4wmw.netlify.app";
|
|
60
60
|
|
|
61
61
|
const handler: Handler = async (event) => {
|
|
62
|
-
const reqContext = startRequest(
|
|
63
|
-
method: event.httpMethod,
|
|
64
|
-
url: event.path,
|
|
65
|
-
headers: event.headers as Record<string, string>,
|
|
66
|
-
body: event.body ?? undefined,
|
|
67
|
-
});
|
|
62
|
+
const reqContext = startRequest(event);
|
|
68
63
|
|
|
69
64
|
try {
|
|
70
65
|
const result = await myBusinessLogic();
|
|
@@ -154,12 +149,7 @@ import { startRequest, finishRequest } from "@replayio-app-building/netlify-reco
|
|
|
154
149
|
import type { Handler } from "@netlify/functions";
|
|
155
150
|
|
|
156
151
|
const handler: Handler = async (event) => {
|
|
157
|
-
const reqContext = startRequest(
|
|
158
|
-
method: event.httpMethod,
|
|
159
|
-
url: event.path,
|
|
160
|
-
headers: event.headers as Record<string, string>,
|
|
161
|
-
body: event.body ?? undefined,
|
|
162
|
-
});
|
|
152
|
+
const reqContext = startRequest(event);
|
|
163
153
|
|
|
164
154
|
try {
|
|
165
155
|
const result = await myBusinessLogic();
|
|
@@ -300,15 +290,12 @@ Self-hosted recording requires these environment variables:
|
|
|
300
290
|
|
|
301
291
|
## API Reference
|
|
302
292
|
|
|
303
|
-
### `startRequest(
|
|
293
|
+
### `startRequest(event): RequestContext`
|
|
304
294
|
|
|
305
|
-
Begins capturing a Netlify handler execution. Patches `globalThis.fetch` and `process.env` to record all outbound network calls and environment variable reads.
|
|
295
|
+
Begins capturing a Netlify handler execution. Accepts the raw Netlify event object and extracts all request metadata internally (method, path, headers, body, query string parameters, etc.). Patches `globalThis.fetch` and `process.env` to record all outbound network calls and environment variable reads.
|
|
306
296
|
|
|
307
297
|
**Parameters:**
|
|
308
|
-
- `
|
|
309
|
-
- `requestInfo.url` — Request URL/path
|
|
310
|
-
- `requestInfo.headers` — Request headers
|
|
311
|
-
- `requestInfo.body` — Optional request body
|
|
298
|
+
- `event` — The Netlify handler event object (passed directly)
|
|
312
299
|
|
|
313
300
|
**Returns:** A `RequestContext` object to pass to `finishRequest`.
|
|
314
301
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalized request metadata stored in the blob.
|
|
3
|
+
* Populated internally by startRequest from the Netlify event.
|
|
4
|
+
*/
|
|
1
5
|
interface RequestInfo {
|
|
2
6
|
method: string;
|
|
3
7
|
url: string;
|
|
4
8
|
headers: Record<string, string>;
|
|
5
9
|
body?: string;
|
|
10
|
+
queryStringParameters?: Record<string, string | undefined> | null;
|
|
11
|
+
multiValueQueryStringParameters?: Record<string, string[] | undefined> | null;
|
|
12
|
+
rawUrl?: string;
|
|
13
|
+
rawQuery?: string;
|
|
14
|
+
isBase64Encoded?: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Input type for startRequest. Mirrors the Netlify HandlerEvent shape so
|
|
18
|
+
* handlers can simply pass the event object directly.
|
|
19
|
+
*/
|
|
20
|
+
interface NetlifyEvent {
|
|
21
|
+
httpMethod: string;
|
|
22
|
+
path: string;
|
|
23
|
+
headers: Record<string, string | undefined>;
|
|
24
|
+
body?: string | null;
|
|
25
|
+
queryStringParameters?: Record<string, string | undefined> | null;
|
|
26
|
+
multiValueQueryStringParameters?: Record<string, string[] | undefined> | null;
|
|
27
|
+
rawUrl?: string;
|
|
28
|
+
rawQuery?: string;
|
|
29
|
+
isBase64Encoded?: boolean;
|
|
6
30
|
}
|
|
7
31
|
interface RequestContext {
|
|
8
32
|
requestInfo: RequestInfo;
|
|
@@ -10,6 +34,9 @@ interface RequestContext {
|
|
|
10
34
|
startTime: number;
|
|
11
35
|
/** Restores original globals (fetch, process.env). Called automatically by finishRequest. */
|
|
12
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>;
|
|
13
40
|
}
|
|
14
41
|
interface CapturedData {
|
|
15
42
|
networkCalls: NetworkCall[];
|
|
@@ -30,6 +57,10 @@ interface EnvRead {
|
|
|
30
57
|
value: string | undefined;
|
|
31
58
|
timestamp: number;
|
|
32
59
|
}
|
|
60
|
+
interface HandlerResponse {
|
|
61
|
+
statusCode: number;
|
|
62
|
+
body?: string;
|
|
63
|
+
}
|
|
33
64
|
interface BlobData {
|
|
34
65
|
requestInfo: RequestInfo;
|
|
35
66
|
capturedData: CapturedData;
|
|
@@ -37,6 +68,8 @@ interface BlobData {
|
|
|
37
68
|
commitSha: string;
|
|
38
69
|
startTime: number;
|
|
39
70
|
endTime: number;
|
|
71
|
+
/** The response returned to the client, used to detect replay mismatches. */
|
|
72
|
+
handlerResponse?: HandlerResponse;
|
|
40
73
|
}
|
|
41
74
|
interface FinishRequestCallbacks {
|
|
42
75
|
/** Uploads serialized captured data and returns the blob URL. */
|
|
@@ -92,18 +125,20 @@ interface EnsureRecordingOptions {
|
|
|
92
125
|
|
|
93
126
|
/**
|
|
94
127
|
* Called at the beginning of a Netlify handler execution.
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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.
|
|
98
133
|
*
|
|
99
134
|
* When running inside createRequestRecording (replay mode), the replay
|
|
100
135
|
* interceptors are already installed. In that case we skip installing
|
|
101
136
|
* capture interceptors to avoid overwriting the replay layer (which would
|
|
102
137
|
* also crash on Node v16 where Headers/Response don't exist).
|
|
103
138
|
*/
|
|
104
|
-
declare function startRequest(
|
|
139
|
+
declare function startRequest(event: NetlifyEvent | /* v2 Request */ Record<string, unknown>): RequestContext;
|
|
105
140
|
|
|
106
|
-
interface
|
|
141
|
+
interface FullHandlerResponse {
|
|
107
142
|
statusCode: number;
|
|
108
143
|
headers?: Record<string, string>;
|
|
109
144
|
body?: string;
|
|
@@ -123,7 +158,7 @@ interface FinishRequestOptions {
|
|
|
123
158
|
* uploads it as a JSON blob via the provided callback,
|
|
124
159
|
* stores the request metadata, and sets the X-Replay-Request-Id header.
|
|
125
160
|
*/
|
|
126
|
-
declare function finishRequest(requestContext: RequestContext, callbacks: FinishRequestCallbacks, response:
|
|
161
|
+
declare function finishRequest(requestContext: RequestContext, callbacks: FinishRequestCallbacks, response: FullHandlerResponse, options?: FinishRequestOptions): Promise<FullHandlerResponse>;
|
|
127
162
|
|
|
128
163
|
/**
|
|
129
164
|
* Creates `FinishRequestCallbacks` that send captured data to a remote
|
|
@@ -181,6 +216,20 @@ declare function ensureRequestRecording(requestId: string, options: EnsureRecord
|
|
|
181
216
|
*/
|
|
182
217
|
declare function readInfraConfigFromEnv(): ContainerInfraConfig | undefined;
|
|
183
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
|
+
}
|
|
184
233
|
/**
|
|
185
234
|
* Called from within a container (running under replay-node) to create a Replay recording.
|
|
186
235
|
* Installs replay-mode interceptors that return pre-recorded responses, then executes
|
|
@@ -189,7 +238,7 @@ declare function readInfraConfigFromEnv(): ContainerInfraConfig | undefined;
|
|
|
189
238
|
* Accepts either a blob URL (fetched at runtime) or pre-parsed BlobData (avoids needing
|
|
190
239
|
* globalThis.fetch, which is missing in replay-node's Node v16 environment).
|
|
191
240
|
*/
|
|
192
|
-
declare function createRequestRecording(blobUrlOrData: string | BlobData, handlerPath: string, requestInfo: RequestInfo): Promise<
|
|
241
|
+
declare function createRequestRecording(blobUrlOrData: string | BlobData, handlerPath: string, requestInfo: RequestInfo): Promise<RecordingResult>;
|
|
193
242
|
|
|
194
243
|
/**
|
|
195
244
|
* Options for spawning a recording container from a blob URL.
|
|
@@ -227,4 +276,4 @@ interface SpawnRecordingContainerOptions {
|
|
|
227
276
|
*/
|
|
228
277
|
declare function spawnRecordingContainer(options: SpawnRecordingContainerOptions): Promise<string>;
|
|
229
278
|
|
|
230
|
-
export { type BlobData, type CapturedData, type ContainerInfraConfig, type EnsureRecordingOptions, type EnvRead, type FinishRequestCallbacks, type FinishRequestOptions, 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
|
@@ -8,6 +8,7 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
8
8
|
// src/interceptors/network.ts
|
|
9
9
|
function installNetworkInterceptor(mode, calls) {
|
|
10
10
|
const originalFetch = globalThis.fetch;
|
|
11
|
+
let replayCallIndex = 0;
|
|
11
12
|
if (mode === "capture") {
|
|
12
13
|
const captureFetch = async (input, init) => {
|
|
13
14
|
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
@@ -39,14 +40,17 @@ function installNetworkInterceptor(mode, calls) {
|
|
|
39
40
|
};
|
|
40
41
|
globalThis.fetch = captureFetch;
|
|
41
42
|
} else {
|
|
42
|
-
let callIndex = 0;
|
|
43
43
|
const replayFetch = async () => {
|
|
44
|
-
const
|
|
44
|
+
const idx = replayCallIndex++;
|
|
45
|
+
const call = calls[idx];
|
|
45
46
|
if (!call) {
|
|
46
47
|
throw new Error(
|
|
47
48
|
`No more recorded network calls to replay (exhausted ${calls.length} calls)`
|
|
48
49
|
);
|
|
49
50
|
}
|
|
51
|
+
console.log(
|
|
52
|
+
` [network-replay] Consumed call ${idx + 1}/${calls.length}: ${call.method} ${call.url} => ${call.responseStatus}`
|
|
53
|
+
);
|
|
50
54
|
const body = call.responseBody ?? "";
|
|
51
55
|
const status = call.responseStatus;
|
|
52
56
|
return {
|
|
@@ -82,6 +86,12 @@ function installNetworkInterceptor(mode, calls) {
|
|
|
82
86
|
return {
|
|
83
87
|
restore() {
|
|
84
88
|
globalThis.fetch = originalFetch;
|
|
89
|
+
},
|
|
90
|
+
consumedCount() {
|
|
91
|
+
return replayCallIndex;
|
|
92
|
+
},
|
|
93
|
+
totalCount() {
|
|
94
|
+
return calls.length;
|
|
85
95
|
}
|
|
86
96
|
};
|
|
87
97
|
}
|
|
@@ -121,7 +131,92 @@ function installEnvironmentInterceptor(mode, reads) {
|
|
|
121
131
|
}
|
|
122
132
|
|
|
123
133
|
// src/startRequest.ts
|
|
124
|
-
function
|
|
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) {
|
|
192
|
+
const headers = {};
|
|
193
|
+
for (const [key, value] of Object.entries(event.headers)) {
|
|
194
|
+
if (value !== void 0) {
|
|
195
|
+
headers[key] = value;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
method: event.httpMethod,
|
|
200
|
+
url: event.path,
|
|
201
|
+
headers,
|
|
202
|
+
body: event.body ?? void 0,
|
|
203
|
+
queryStringParameters: event.queryStringParameters ?? null,
|
|
204
|
+
multiValueQueryStringParameters: event.multiValueQueryStringParameters ?? null,
|
|
205
|
+
rawUrl: event.rawUrl,
|
|
206
|
+
rawQuery: event.rawQuery,
|
|
207
|
+
isBase64Encoded: event.isBase64Encoded
|
|
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
|
+
}
|
|
125
220
|
const capturedData = { networkCalls: [], envReads: [] };
|
|
126
221
|
const isReplay = globalThis.__REPLAY_RECORDING_MODE__ === true;
|
|
127
222
|
let cleanup;
|
|
@@ -140,7 +235,8 @@ function startRequest(requestInfo) {
|
|
|
140
235
|
requestInfo,
|
|
141
236
|
capturedData,
|
|
142
237
|
startTime: Date.now(),
|
|
143
|
-
cleanup
|
|
238
|
+
cleanup,
|
|
239
|
+
_v2BodyPromise: bodyPromise
|
|
144
240
|
};
|
|
145
241
|
}
|
|
146
242
|
|
|
@@ -291,12 +387,25 @@ async function finishRequest(requestContext, callbacks, response, options) {
|
|
|
291
387
|
const commitSha = rawCommitSha;
|
|
292
388
|
const branchName = rawBranchName;
|
|
293
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
|
+
}
|
|
294
399
|
const rawBlobData = {
|
|
295
400
|
requestInfo: requestContext.requestInfo,
|
|
296
401
|
capturedData: requestContext.capturedData,
|
|
297
402
|
commitSha,
|
|
298
403
|
startTime: requestContext.startTime,
|
|
299
|
-
endTime: Date.now()
|
|
404
|
+
endTime: Date.now(),
|
|
405
|
+
handlerResponse: {
|
|
406
|
+
statusCode: response.statusCode,
|
|
407
|
+
body: response.body
|
|
408
|
+
}
|
|
300
409
|
};
|
|
301
410
|
const blobData = redactBlobData(rawBlobData);
|
|
302
411
|
const blobContent = JSON.stringify(blobData);
|
|
@@ -648,19 +757,241 @@ async function createRequestRecording(blobUrlOrData, handlerPath, requestInfo) {
|
|
|
648
757
|
};
|
|
649
758
|
g.File = FileShim;
|
|
650
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 };
|
|
651
861
|
try {
|
|
652
862
|
const handlerModule = await import(handlerPath);
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
+
}
|
|
659
966
|
} finally {
|
|
967
|
+
const consumed = networkHandle.consumedCount();
|
|
968
|
+
const total = networkHandle.totalCount();
|
|
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
|
+
];
|
|
974
|
+
console.error(
|
|
975
|
+
`ERROR: Not all recorded network calls were consumed during replay. Consumed ${consumed} of ${total} calls. ${total - consumed} call(s) were never replayed.`
|
|
976
|
+
);
|
|
977
|
+
for (let i = consumed; i < total; i++) {
|
|
978
|
+
const call = blobData.capturedData.networkCalls[i];
|
|
979
|
+
if (call) {
|
|
980
|
+
details.push(`Unconsumed: ${call.method} ${call.url} => ${call.responseStatus}`);
|
|
981
|
+
console.error(
|
|
982
|
+
` Unconsumed call ${i + 1}/${total}: ${call.method} ${call.url} => ${call.responseStatus}`
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
result.unconsumedNetworkDetails = details.join("; ");
|
|
987
|
+
} else {
|
|
988
|
+
console.log(` [network-replay] All ${total} recorded network call(s) were consumed.`);
|
|
989
|
+
}
|
|
660
990
|
globalThis.__REPLAY_RECORDING_MODE__ = false;
|
|
661
991
|
networkHandle.restore();
|
|
662
992
|
envHandle.restore();
|
|
663
993
|
}
|
|
994
|
+
return result;
|
|
664
995
|
}
|
|
665
996
|
export {
|
|
666
997
|
createRequestRecording,
|