Skip to content

Commit 671ffb9

Browse files
committed
feat(networkLogs): optional request/response body capture with truncation\n\n- Core: add networkLogs.bodies options (request/response, maxBytes, allowContentTypes, prettyJson)\n- Core: capture fetch/XHR bodies with content-type allowlist and byte caps\n- Vite: propagate bodies options into virtual client; render snippets\n- Next: expose bodies options in BrowserEchoScript and client code\n- Nuxt: extend module options to include bodies passthrough\n- Discovery: constrain Vite discovery to cwd; fix tests (no default 5179 probe)
1 parent 899d828 commit 671ffb9

6 files changed

Lines changed: 327 additions & 46 deletions

File tree

packages/core/src/client.ts

Lines changed: 234 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ export function initBrowserEcho(opts: InitBrowserEchoOptions = {}) {
4141
// Optional: network capture (fetch + XHR)
4242
const networkEnabled = !!opts.networkLogs?.enabled;
4343
const networkFull = !!opts.networkLogs?.captureFull;
44+
const bodiesCfg = opts.networkLogs?.bodies || {};
45+
const bodyReqEnabled = !!bodiesCfg.request;
46+
const bodyResEnabled = !!bodiesCfg.response;
47+
const bodyMaxBytes = (bodiesCfg.maxBytes ?? 2048) | 0;
48+
const bodyPrettyJson = bodiesCfg.prettyJson !== false;
49+
const bodyAllowed: string[] = (Array.isArray(bodiesCfg.allowContentTypes) && bodiesCfg.allowContentTypes.length)
50+
? bodiesCfg.allowContentTypes.map((s) => String(s).toLowerCase())
51+
: ['application/json', 'text/', 'application/x-www-form-urlencoded'];
4452
if (networkEnabled) {
4553
try { installFetchCapture(); } catch {}
4654
try { installXhrCapture(); } catch {}
@@ -146,31 +154,90 @@ export function initBrowserEcho(opts: InitBrowserEchoOptions = {}) {
146154
const start = performance.now();
147155
const method = (init?.method || (typeof input === 'object' && input?.method) || 'GET').toUpperCase();
148156
const url = normalizeUrlString(input);
149-
const emit = (status: number, ok: boolean, extra?: string) => {
150-
const dur = Math.max(0, Math.round(performance.now() - start));
157+
const baseLine = (status: number, durMs: number) => {
151158
const statusText = isFinite(status as any) ? String(status) : 'ERR';
152-
const text = `[NETWORK] [${method}] [${url || '(request)'}] [${statusText}] [${dur}ms]${extra ? ' ' + extra : ''}`;
153-
enqueue({ level: ok ? 'info' : 'warn', text, time: Date.now(), tag: '[network]' });
159+
return `[NETWORK] [${method}] [${url || '(request)'}] [${statusText}] [${durMs}ms]`;
160+
};
161+
const getReqSnippet = (): Promise<string> => {
162+
if (!bodyReqEnabled) return Promise.resolve('');
163+
try {
164+
// Prefer Request.clone() if available
165+
if (input && typeof input === 'object' && typeof (input as any).clone === 'function') {
166+
const req: any = input;
167+
const headers = (req.headers && typeof req.headers.get === 'function') ? req.headers : null;
168+
const ct = getHeader(headers, 'content-type') || (init?.headers ? getHeader(init?.headers, 'content-type') : '');
169+
if (!isAllowedContentType(ct)) return Promise.resolve('');
170+
return req.clone().text().then((txt: string) => formatBodySnippet(txt, ct));
171+
}
172+
// Fallback to init.body as string/urlencoded
173+
const ct = init?.headers ? getHeader(init.headers, 'content-type') : '';
174+
const body = init?.body;
175+
if (typeof body === 'string') {
176+
if (!ct || isAllowedContentType(ct) || isLikelyText(body)) return Promise.resolve(formatBodySnippet(body, ct));
177+
} else if (body && typeof (body as any).toString === 'function' && (body instanceof URLSearchParams)) {
178+
const s = (body as URLSearchParams).toString();
179+
const reqCt = ct || 'application/x-www-form-urlencoded';
180+
if (isAllowedContentType(reqCt)) return Promise.resolve(formatBodySnippet(s, reqCt));
181+
} else if (body && typeof (body as any).size === 'number') {
182+
const size = Number((body as any).size) | 0;
183+
return Promise.resolve(`[binary: ${size} bytes]`);
184+
}
185+
} catch {}
186+
return Promise.resolve('');
187+
};
188+
const getResSnippet = (res: any): Promise<string> => {
189+
if (!bodyResEnabled) return Promise.resolve('');
190+
try {
191+
const headers = res?.headers;
192+
const ct = getHeader(headers, 'content-type');
193+
if (!isAllowedContentType(ct)) return Promise.resolve('');
194+
if (res && typeof res.clone === 'function') {
195+
try {
196+
const clone = res.clone();
197+
if (clone && clone.body && typeof clone.body.getReader === 'function') {
198+
return readStreamSnippet(clone, ct);
199+
}
200+
return clone.text().then((txt: string) => formatBodySnippet(txt, ct));
201+
} catch {}
202+
}
203+
} catch {}
204+
return Promise.resolve('');
154205
};
155206
try {
156207
const p = orig(input, init);
157208
return Promise.resolve(p).then((res: any) => {
158-
try {
159-
if (networkFull) {
160-
const headers: any = {};
161-
try { res.headers && res.headers.forEach && res.headers.forEach((v: string, k: string) => { headers[k] = v; }); } catch {}
162-
emit(Number(res?.status ?? 0) | 0, !!res?.ok, `[size:${Number(res?.headers?.get?.('content-length') || 0) | 0}]`);
163-
} else {
164-
emit(Number(res?.status ?? 0) | 0, !!res?.ok);
165-
}
166-
} catch {}
209+
const dur = Math.max(0, Math.round(performance.now() - start));
210+
const statusNum = Number(res?.status ?? 0) | 0;
211+
const ok = !!res?.ok;
212+
const extra = networkFull ? ` [size:${Number(res?.headers?.get?.('content-length') || 0) | 0}]` : '';
213+
// Prepare body snippets asynchronously
214+
Promise.all([getReqSnippet(), getResSnippet(res)]).then(([reqS, resS]) => {
215+
let line = baseLine(statusNum, dur) + extra;
216+
if (reqS) line += `\n req: ${reqS}`;
217+
if (resS) line += `\n res: ${resS}`;
218+
enqueue({ level: ok ? 'info' : 'warn', text: line, time: Date.now(), tag: '[network]' });
219+
}).catch(() => {
220+
const line = baseLine(statusNum, dur) + extra;
221+
enqueue({ level: ok ? 'info' : 'warn', text: line, time: Date.now(), tag: '[network]' });
222+
});
167223
return res;
168224
}).catch((err: any) => {
169-
emit(0, false, err?.message ? String(err.message) : 'fetch failed');
225+
const dur = Math.max(0, Math.round(performance.now() - start));
226+
Promise.resolve(getReqSnippet()).then((reqS) => {
227+
let line = baseLine(0, dur);
228+
line += ` fetch failed`;
229+
if (reqS) line += `\n req: ${reqS}`;
230+
enqueue({ level: 'warn', text: line, time: Date.now(), tag: '[network]' });
231+
}).catch(() => {
232+
const line = baseLine(0, dur) + ' fetch failed';
233+
enqueue({ level: 'warn', text: line, time: Date.now(), tag: '[network]' });
234+
});
170235
throw err;
171236
});
172237
} catch (err: any) {
173-
emit(0, false, err?.message ? String(err.message) : 'fetch failed');
238+
const dur = Math.max(0, Math.round(performance.now() - start));
239+
let line = baseLine(0, dur) + ' fetch failed';
240+
enqueue({ level: 'warn', text: line, time: Date.now(), tag: '[network]' });
174241
throw err;
175242
}
176243
};
@@ -181,23 +248,58 @@ export function initBrowserEcho(opts: InitBrowserEchoOptions = {}) {
181248
if (!XHR || !XHR.prototype) return;
182249
const origOpen = XHR.prototype.open;
183250
const origSend = XHR.prototype.send;
251+
const origSetHeader = XHR.prototype.setRequestHeader;
184252
XHR.prototype.open = function(method: string, url: string) {
185253
try { (this as any).__be_method__ = String(method || 'GET').toUpperCase(); } catch {}
186254
try { (this as any).__be_url__ = String(url || ''); } catch {}
187255
return origOpen.apply(this, arguments as any);
188256
} as any;
257+
if (origSetHeader) {
258+
XHR.prototype.setRequestHeader = function(name: string, value: string) {
259+
try {
260+
const k = String(name || '').toLowerCase();
261+
if (k === 'content-type') { (this as any).__be_req_ct__ = String(value || ''); }
262+
} catch {}
263+
return origSetHeader.apply(this, arguments as any);
264+
} as any;
265+
}
189266
XHR.prototype.send = function() {
190267
const start = performance.now();
268+
try { if (bodyReqEnabled) { (this as any).__be_req_body__ = arguments && arguments[0]; } } catch {}
191269
const onEnd = () => {
192270
try {
193271
const dur = Math.max(0, Math.round(performance.now() - start));
194272
const method = (this as any).__be_method__ || 'GET';
195273
const u = (this as any).__be_url__ || '';
196274
const status = Number((this as any).status ?? 0) | 0;
197275
const ok = status >= 200 && status < 400;
198-
const extra = networkFull ? `ready:${(this as any).readyState}` : '';
199-
const text = `[NETWORK] [${method}] [${u}] [${status || 'ERR'}] [${dur}ms]${extra ? ' ' + extra : ''}`;
200-
enqueue({ level: ok ? 'info' : 'warn', text, time: Date.now(), tag: '[network]' });
276+
const extra = networkFull ? ` ready:${(this as any).readyState}` : '';
277+
let line = `[NETWORK] [${method}] [${u}] [${status || 'ERR'}] [${dur}ms]${extra}`;
278+
// Bodies
279+
if (bodyReqEnabled) {
280+
try {
281+
const reqCt = String((this as any).__be_req_ct__ || '').toLowerCase();
282+
const reqBody = (this as any).__be_req_body__;
283+
const reqSnippet = formatRequestBodySync(reqBody, reqCt);
284+
if (reqSnippet) line += `\n req: ${reqSnippet}`;
285+
} catch {}
286+
}
287+
if (bodyResEnabled) {
288+
try {
289+
const resCt = String((this as any).getResponseHeader?.('Content-Type') || '').toLowerCase();
290+
if (isAllowedContentType(resCt)) {
291+
let snippet = '';
292+
const rt = (this as any).responseType;
293+
if (!rt || rt === 'text') {
294+
try { snippet = formatBodySnippet(String((this as any).responseText || ''), resCt); } catch {}
295+
} else if (rt === 'json') {
296+
try { snippet = formatBodySnippet(JSON.stringify((this as any).response ?? null), 'application/json'); } catch {}
297+
}
298+
if (snippet) line += `\n res: ${snippet}`;
299+
}
300+
} catch {}
301+
}
302+
enqueue({ level: ok ? 'info' : 'warn', text: line, time: Date.now(), tag: '[network]' });
201303
} catch {}
202304
try {
203305
this.removeEventListener('loadend', onEnd);
@@ -245,6 +347,120 @@ export function initBrowserEcho(opts: InitBrowserEchoOptions = {}) {
245347
});
246348
}
247349

350+
function getHeader(headers: any, name: string): string {
351+
try {
352+
if (!headers) return '';
353+
const key = String(name).toLowerCase();
354+
if (typeof headers.get === 'function') {
355+
const v = headers.get(name) || headers.get(key) || '';
356+
return String(v || '').toLowerCase();
357+
}
358+
if (Array.isArray(headers)) {
359+
for (const [k, v] of headers) {
360+
if (String(k).toLowerCase() === key) return String(v || '').toLowerCase();
361+
}
362+
}
363+
if (typeof headers === 'object') {
364+
for (const k of Object.keys(headers)) {
365+
if (k.toLowerCase() === key) return String((headers as any)[k] || '').toLowerCase();
366+
}
367+
}
368+
} catch {}
369+
return '';
370+
}
371+
372+
function isAllowedContentType(ct: string): boolean {
373+
try {
374+
const c = String(ct || '').toLowerCase();
375+
if (!c) return false;
376+
for (const a of bodyAllowed) {
377+
const al = String(a);
378+
if (c.startsWith(al)) return true;
379+
}
380+
} catch {}
381+
return false;
382+
}
383+
384+
function isLikelyText(s: string): boolean {
385+
const trimmed = String(s || '').trim();
386+
if (!trimmed) return true;
387+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) return true;
388+
return /^[\x09\x0A\x0D\x20-\x7E\u00A0-\uFFFF]*$/.test(trimmed);
389+
}
390+
391+
function formatBodySnippet(raw: string, contentType: string): string {
392+
try {
393+
let text = String(raw ?? '');
394+
const ct = String(contentType || '').toLowerCase();
395+
if (bodyPrettyJson && (ct.startsWith('application/json') || (text.trim().startsWith('{') || text.trim().startsWith('[')))) {
396+
try { text = JSON.stringify(JSON.parse(text), null, 2); } catch {}
397+
}
398+
const enc = new TextEncoder();
399+
const bytes = enc.encode(text);
400+
if (bytes.length <= bodyMaxBytes) return text;
401+
const sliced = bytes.slice(0, Math.max(0, bodyMaxBytes));
402+
const dec = new TextDecoder();
403+
const shown = dec.decode(sliced);
404+
const extra = bytes.length - sliced.length;
405+
return `${shown}… (+${extra} bytes)`;
406+
} catch { return ''; }
407+
}
408+
409+
function formatRequestBodySync(body: any, contentType: string): string {
410+
try {
411+
const ct = String(contentType || '').toLowerCase();
412+
if (!ct || !isAllowedContentType(ct)) {
413+
if (typeof body === 'string' && isLikelyText(body)) return formatBodySnippet(body, '');
414+
return '';
415+
}
416+
if (typeof body === 'string') return formatBodySnippet(body, ct);
417+
if (body instanceof URLSearchParams) return formatBodySnippet(body.toString(), 'application/x-www-form-urlencoded');
418+
if (body && typeof body.size === 'number') return `[binary: ${Number(body.size) | 0} bytes]`;
419+
} catch {}
420+
return '';
421+
}
422+
423+
async function readStreamSnippet(resClone: any, contentType: string): Promise<string> {
424+
try {
425+
const reader = resClone.body?.getReader?.();
426+
if (!reader) return resClone.text().then((t: string) => formatBodySnippet(t, contentType));
427+
const chunks: Uint8Array[] = [];
428+
let received = 0;
429+
while (true) {
430+
const { done, value } = await reader.read();
431+
if (done) break;
432+
if (value) {
433+
const v = value as Uint8Array;
434+
if (received < bodyMaxBytes) {
435+
const need = bodyMaxBytes - received;
436+
chunks.push(need >= v.length ? v : v.slice(0, need));
437+
}
438+
received += v.length;
439+
if (received >= bodyMaxBytes) {
440+
try { reader.cancel && reader.cancel(); } catch {}
441+
break;
442+
}
443+
}
444+
}
445+
const merged = mergeUint8Arrays(chunks);
446+
const dec = new TextDecoder();
447+
const shown = dec.decode(merged);
448+
if (received <= bodyMaxBytes) return formatBodySnippet(shown, contentType);
449+
const extra = received - merged.length;
450+
return `${shown}… (+${extra} bytes)`;
451+
} catch {
452+
try { const t = await resClone.text(); return formatBodySnippet(t, contentType); } catch { return ''; }
453+
}
454+
}
455+
456+
function mergeUint8Arrays(arrays: Uint8Array[]): Uint8Array {
457+
const total = arrays.reduce((n, a) => n + a.length, 0);
458+
const out = new Uint8Array(total);
459+
let off = 0;
460+
for (const a of arrays) { out.set(a, off); off += a.length; }
461+
return out;
462+
}
463+
248464
function randomId() {
249465
try {
250466
const arr = new Uint8Array(8);

packages/core/src/types.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,15 @@ export interface InitBrowserEchoOptions {
77
tag?: string;
88
batch?: { size?: number; interval?: number };
99
stackMode?: 'full' | 'condensed' | 'none';
10-
networkLogs?: { enabled?: boolean; captureFull?: boolean };
10+
networkLogs?: {
11+
enabled?: boolean;
12+
captureFull?: boolean;
13+
bodies?: {
14+
request?: boolean;
15+
response?: boolean;
16+
maxBytes?: number;
17+
allowContentTypes?: string[];
18+
prettyJson?: boolean;
19+
};
20+
};
1121
}

0 commit comments

Comments
 (0)