Skip to content

Commit 10ede7e

Browse files
fix: resolve 6 Copilot review comments on PR #768
- httpClient: replace require() with ESM dynamic import() for store access - useConversationActions: pass explicit undefined to resetChat() - parseBrief: route to /chat endpoint (no /brief/parse backend route) - streamChat: use httpClient.post + yield instead of SSE (backend returns JSON) - selectProducts: route to /chat endpoint (no /products/select backend route) - streamRegenerateImage: use /chat + polling instead of non-existent /regenerate SSE - remove unused readSSEResponse helper and parseSSEStream import
1 parent 0f57d1e commit 10ede7e

9 files changed

Lines changed: 1788 additions & 60 deletions

File tree

content-gen/src/app/frontend/optimization-report.html

Lines changed: 1244 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/**
2+
* Centralized HTTP client with interceptors.
3+
*
4+
* - Singleton — use the default `httpClient` export everywhere.
5+
* - Request interceptors automatically attach auth headers
6+
* (X-Ms-Client-Principal-Id) so callers never need to remember.
7+
* - Response interceptors provide uniform error handling.
8+
* - Built-in query-param serialization, configurable timeout, and base URL.
9+
*/
10+
11+
/* ------------------------------------------------------------------ */
12+
/* Types */
13+
/* ------------------------------------------------------------------ */
14+
15+
/** Options accepted by every request method. */
16+
export interface RequestOptions extends Omit<RequestInit, 'method' | 'body'> {
17+
/** Query parameters – appended to the URL automatically. */
18+
params?: Record<string, string | number | boolean | undefined>;
19+
/** Per-request timeout in ms (default: client-level `timeout`). */
20+
timeout?: number;
21+
}
22+
23+
type RequestInterceptor = (url: string, init: RequestInit) => RequestInit | Promise<RequestInit>;
24+
type ResponseInterceptor = (response: Response) => Response | Promise<Response>;
25+
26+
/* ------------------------------------------------------------------ */
27+
/* HttpClient */
28+
/* ------------------------------------------------------------------ */
29+
30+
export class HttpClient {
31+
private baseUrl: string;
32+
private defaultTimeout: number;
33+
private requestInterceptors: RequestInterceptor[] = [];
34+
private responseInterceptors: ResponseInterceptor[] = [];
35+
36+
constructor(baseUrl = '', timeout = 60_000) {
37+
this.baseUrl = baseUrl;
38+
this.defaultTimeout = timeout;
39+
}
40+
41+
/* ---------- interceptor registration ---------- */
42+
43+
onRequest(fn: RequestInterceptor): void {
44+
this.requestInterceptors.push(fn);
45+
}
46+
47+
onResponse(fn: ResponseInterceptor): void {
48+
this.responseInterceptors.push(fn);
49+
}
50+
51+
/* ---------- public request helpers ---------- */
52+
53+
async get<T = unknown>(path: string, opts: RequestOptions = {}): Promise<T> {
54+
const res = await this.request(path, { ...opts, method: 'GET' });
55+
return res.json() as Promise<T>;
56+
}
57+
58+
async post<T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}): Promise<T> {
59+
const res = await this.request(path, {
60+
...opts,
61+
method: 'POST',
62+
body: body != null ? JSON.stringify(body) : undefined,
63+
headers: {
64+
...(body != null ? { 'Content-Type': 'application/json' } : {}),
65+
...opts.headers,
66+
},
67+
});
68+
return res.json() as Promise<T>;
69+
}
70+
71+
async put<T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}): Promise<T> {
72+
const res = await this.request(path, {
73+
...opts,
74+
method: 'PUT',
75+
body: body != null ? JSON.stringify(body) : undefined,
76+
headers: {
77+
...(body != null ? { 'Content-Type': 'application/json' } : {}),
78+
...opts.headers,
79+
},
80+
});
81+
return res.json() as Promise<T>;
82+
}
83+
84+
async delete<T = unknown>(path: string, opts: RequestOptions = {}): Promise<T> {
85+
const res = await this.request(path, { ...opts, method: 'DELETE' });
86+
return res.json() as Promise<T>;
87+
}
88+
89+
/**
90+
* Low-level request that returns the raw `Response`.
91+
* Useful for streaming (SSE) endpoints where the caller needs `response.body`.
92+
*/
93+
async raw(path: string, opts: RequestOptions & { method?: string; body?: BodyInit | null } = {}): Promise<Response> {
94+
return this.request(path, opts);
95+
}
96+
97+
/* ---------- internal plumbing ---------- */
98+
99+
private buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
100+
const url = `${this.baseUrl}${path}`;
101+
if (!params) return url;
102+
103+
const qs = new URLSearchParams();
104+
for (const [key, value] of Object.entries(params)) {
105+
if (value !== undefined) {
106+
qs.set(key, String(value));
107+
}
108+
}
109+
const queryString = qs.toString();
110+
return queryString ? `${url}?${queryString}` : url;
111+
}
112+
113+
private async request(path: string, opts: RequestOptions & { method?: string; body?: BodyInit | null } = {}): Promise<Response> {
114+
const { params, timeout, ...fetchOpts } = opts;
115+
const url = this.buildUrl(path, params);
116+
const effectiveTimeout = timeout ?? this.defaultTimeout;
117+
118+
// Build the init object
119+
let init: RequestInit = { ...fetchOpts };
120+
121+
// Run request interceptors
122+
for (const interceptor of this.requestInterceptors) {
123+
init = await interceptor(url, init);
124+
}
125+
126+
// Timeout via AbortController (merged with caller-supplied signal)
127+
const timeoutCtrl = new AbortController();
128+
const callerSignal = init.signal;
129+
130+
// If caller already passed a signal, listen for its abort
131+
if (callerSignal) {
132+
if (callerSignal.aborted) {
133+
timeoutCtrl.abort(callerSignal.reason);
134+
} else {
135+
callerSignal.addEventListener('abort', () => timeoutCtrl.abort(callerSignal.reason), { once: true });
136+
}
137+
}
138+
139+
const timer = effectiveTimeout > 0
140+
? setTimeout(() => timeoutCtrl.abort(new DOMException('Request timed out', 'TimeoutError')), effectiveTimeout)
141+
: undefined;
142+
143+
init.signal = timeoutCtrl.signal;
144+
145+
try {
146+
let response = await fetch(url, init);
147+
148+
// Run response interceptors
149+
for (const interceptor of this.responseInterceptors) {
150+
response = await interceptor(response);
151+
}
152+
153+
return response;
154+
} finally {
155+
if (timer !== undefined) clearTimeout(timer);
156+
}
157+
}
158+
}
159+
160+
/* ------------------------------------------------------------------ */
161+
/* Singleton instance with default interceptors */
162+
/* ------------------------------------------------------------------ */
163+
164+
const httpClient = new HttpClient('/api');
165+
166+
/**
167+
* Client for Azure platform endpoints (/.auth/me, etc.) — no base URL prefix.
168+
* Shares the same interceptor pattern but targets the host root.
169+
*/
170+
export const platformClient = new HttpClient('', 10_000);
171+
172+
// ---- request interceptor: auth headers ----
173+
httpClient.onRequest(async (_url, init) => {
174+
const headers = new Headers(init.headers);
175+
176+
// Attach userId from Redux store (lazy import to avoid circular deps).
177+
// Falls back to 'anonymous' if store isn't ready yet.
178+
try {
179+
const { store } = await import('../store/store');
180+
const state = store?.getState?.();
181+
const userId: string = state?.app?.userId ?? 'anonymous';
182+
headers.set('X-Ms-Client-Principal-Id', userId);
183+
} catch {
184+
headers.set('X-Ms-Client-Principal-Id', 'anonymous');
185+
}
186+
187+
return { ...init, headers };
188+
});
189+
190+
// ---- response interceptor: uniform error handling ----
191+
httpClient.onResponse((response) => {
192+
if (!response.ok) {
193+
// Don't throw for streaming endpoints — callers handle those manually.
194+
// Clone so the body remains readable for callers that want custom handling.
195+
const cloned = response.clone();
196+
console.error(
197+
`[httpClient] ${response.status} ${response.statusText}${cloned.url}`,
198+
);
199+
}
200+
return response;
201+
});
202+
203+
export default httpClient;

0 commit comments

Comments
 (0)