Skip to content

Commit 16fbe87

Browse files
cmcWebCode40claude
andcommitted
feat: add request replay utility with response comparison
Enable replaying captured network requests with optional header/body modifications. Includes timeout support, safety warnings for destructive methods (POST, PUT, DELETE), and response comparison to detect changes. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent dd2331a commit 16fbe87

1 file changed

Lines changed: 164 additions & 0 deletions

File tree

src/utils/replay.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import type { NetworkLog, NetworkResponse } from '../types';
2+
3+
export interface ReplayResult {
4+
success: boolean;
5+
response?: NetworkResponse;
6+
error?: string;
7+
originalLog: NetworkLog;
8+
replayedAt: string;
9+
}
10+
11+
export interface ReplayOptions {
12+
modifyHeaders?: Record<string, string>;
13+
modifyBody?: string;
14+
timeout?: number;
15+
}
16+
17+
export const replayRequest = async (
18+
log: NetworkLog,
19+
options: ReplayOptions = {}
20+
): Promise<ReplayResult> => {
21+
const startTime = Date.now();
22+
const replayedAt = new Date().toISOString();
23+
24+
const headers: Record<string, string> = {
25+
...log.headers,
26+
...options.modifyHeaders,
27+
};
28+
29+
const fetchOptions: RequestInit = {
30+
method: log.method,
31+
headers,
32+
};
33+
34+
if (log.body || options.modifyBody) {
35+
fetchOptions.body = options.modifyBody || log.body || undefined;
36+
}
37+
38+
try {
39+
const controller = new AbortController();
40+
const timeoutId = setTimeout(
41+
() => controller.abort(),
42+
options.timeout || 30000
43+
);
44+
45+
fetchOptions.signal = controller.signal;
46+
47+
const response = await fetch(log.url, fetchOptions);
48+
clearTimeout(timeoutId);
49+
50+
let responseBody = '';
51+
try {
52+
responseBody = await response.text();
53+
} catch {
54+
responseBody = 'Unable to read response body';
55+
}
56+
57+
const duration = Date.now() - startTime;
58+
59+
const networkResponse: NetworkResponse = {
60+
status: response.status,
61+
statusText: response.statusText,
62+
headers: headersToObject(response.headers),
63+
body: responseBody,
64+
duration,
65+
};
66+
67+
return {
68+
success: true,
69+
response: networkResponse,
70+
originalLog: log,
71+
replayedAt,
72+
};
73+
} catch (error) {
74+
const errorMessage =
75+
error instanceof Error ? error.message : 'Unknown error';
76+
77+
return {
78+
success: false,
79+
error: errorMessage,
80+
originalLog: log,
81+
replayedAt,
82+
};
83+
}
84+
};
85+
86+
const headersToObject = (headers: Headers): Record<string, string> => {
87+
const obj: Record<string, string> = {};
88+
headers.forEach((value, key) => {
89+
obj[key] = value;
90+
});
91+
return obj;
92+
};
93+
94+
export const canReplayRequest = (log: NetworkLog): boolean => {
95+
const replayableMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'];
96+
return replayableMethods.includes(log.method.toUpperCase());
97+
};
98+
99+
export const getReplayWarnings = (log: NetworkLog): string[] => {
100+
const warnings: string[] = [];
101+
102+
if (log.method === 'POST' || log.method === 'PUT' || log.method === 'PATCH') {
103+
warnings.push('This request may modify data on the server');
104+
}
105+
106+
if (log.method === 'DELETE') {
107+
warnings.push('This request may delete data on the server');
108+
}
109+
110+
const hasAuthHeader = Object.keys(log.headers || {}).some((key) =>
111+
['authorization', 'x-api-key', 'cookie'].includes(key.toLowerCase())
112+
);
113+
114+
if (hasAuthHeader) {
115+
warnings.push('Request contains authentication headers');
116+
}
117+
118+
return warnings;
119+
};
120+
121+
export const compareResponses = (
122+
original: NetworkResponse | undefined,
123+
replayed: NetworkResponse | undefined
124+
): {
125+
statusChanged: boolean;
126+
bodyChanged: boolean;
127+
durationDiff: number;
128+
summary: string;
129+
} => {
130+
if (!original || !replayed) {
131+
return {
132+
statusChanged: false,
133+
bodyChanged: false,
134+
durationDiff: 0,
135+
summary: 'Unable to compare responses',
136+
};
137+
}
138+
139+
const statusChanged = original.status !== replayed.status;
140+
const bodyChanged = original.body !== replayed.body;
141+
const durationDiff = replayed.duration - original.duration;
142+
143+
const summaryParts: string[] = [];
144+
145+
if (statusChanged) {
146+
summaryParts.push(`Status: ${original.status}${replayed.status}`);
147+
}
148+
149+
if (bodyChanged) {
150+
summaryParts.push('Response body changed');
151+
}
152+
153+
if (Math.abs(durationDiff) > 100) {
154+
const sign = durationDiff > 0 ? '+' : '';
155+
summaryParts.push(`Duration: ${sign}${durationDiff}ms`);
156+
}
157+
158+
return {
159+
statusChanged,
160+
bodyChanged,
161+
durationDiff,
162+
summary: summaryParts.length > 0 ? summaryParts.join(', ') : 'No changes',
163+
};
164+
};

0 commit comments

Comments
 (0)