diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b3d13c..bf861da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2025-09-XX + +### Added +- **Network Logs (opt-in)**: Capture fetch, XMLHttpRequest, and WebSocket as `[network]` entries alongside console logs. Disabled by default; enable via `networkLogs.enabled: true` (Vite/Next/Nuxt/Core). +- **Tag Filtering**: `get_logs({ tag })` and diagnostics `GET /__client-logs?tag=...` to filter by stream tag (`[browser]`, `[network]`, `[worker]`). +- **Worker Runtime (internal)**: Dev-only worker console capture runtime available in core (not exported publicly by default). +- **MCP File Logging (opt-in)**: Enable ingest-side file logging with `BROWSER_ECHO_FILE_LOG=true` and optional split via `BROWSER_ECHO_SPLIT_LOGS=true`. + +### Changed +- Removed protocol from network log text. Format now: `[NETWORK] [METHOD] [URL] [STATUS] [DURATION ms]` and WS events `[WS OPEN/CLOSE/ERROR]`. +- Next/Nuxt handlers now suppress terminal only when `BROWSER_ECHO_MCP_URL` is set. + ## [1.0.2] - 2025-09-XX ### Fixed diff --git a/README.md b/README.md index da178ab..d88c652 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Stream browser `console.*` logs to your dev terminal and optional file logging. - Colorized terminal output - Optional file logging (Vite provider only) - Works great with AI assistants reading your terminal + - Optional network capture (opt‑in): fetch, XMLHttpRequest, WebSocket ## Production diff --git a/example/next-app/package.json b/example/next-app/package.json index 76ed9cc..3245201 100644 --- a/example/next-app/package.json +++ b/example/next-app/package.json @@ -14,8 +14,8 @@ "react-dom": "19.1.1" }, "devDependencies": { - "@browser-echo/mcp": "workspace:*", - "@browser-echo/next": "workspace:*", + "@browser-echo/mcp": "1.0.2", + "@browser-echo/next": "1.0.2", "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4.1.12", "@types/node": "^24.3.0", diff --git a/example/nuxt-app/package.json b/example/nuxt-app/package.json index 66aaf08..8b960a0 100644 --- a/example/nuxt-app/package.json +++ b/example/nuxt-app/package.json @@ -16,6 +16,6 @@ }, "packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac", "devDependencies": { - "@browser-echo/nuxt": "workspace:*" + "@browser-echo/nuxt": "1.0.2" } } diff --git a/example/react-vite-app/package.json b/example/react-vite-app/package.json index 967e4c2..64e45e9 100644 --- a/example/react-vite-app/package.json +++ b/example/react-vite-app/package.json @@ -14,7 +14,7 @@ "react-dom": "^19.1.1" }, "devDependencies": { - "@browser-echo/vite": "workspace:*", + "@browser-echo/vite": "1.0.2", "@eslint/js": "^9.17.0", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", diff --git a/example/tanstack-app/package.json b/example/tanstack-app/package.json index fa19409..e440678 100644 --- a/example/tanstack-app/package.json +++ b/example/tanstack-app/package.json @@ -18,7 +18,7 @@ "zod": "^4.0.17" }, "devDependencies": { - "@browser-echo/vite": "workspace:*", + "@browser-echo/vite": "1.0.2", "@types/node": "^24.3.0", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", diff --git a/example/vue-vite-app/package.json b/example/vue-vite-app/package.json index d5f80de..07d11ee 100644 --- a/example/vue-vite-app/package.json +++ b/example/vue-vite-app/package.json @@ -12,7 +12,7 @@ "vue": "^3.5.18" }, "devDependencies": { - "@browser-echo/vite": "workspace:*", + "@browser-echo/vite": "1.0.2", "@vitejs/plugin-vue": "^6.0.1", "typescript": "~5.9.2", "vite": "^7.1.3", diff --git a/packages/core/README.md b/packages/core/README.md index 6c0efe9..6ae2a0a 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -18,6 +18,7 @@ This package provides the `initBrowserEcho` function that patches `console.*` me - Configurable log levels and batching - Circular reference handling in logged objects - No production impact (meant for development only) + - Optional network capture (opt-in): fetch, XMLHttpRequest, WebSocket ## Installation @@ -42,7 +43,19 @@ initBrowserEcho({ preserveConsole: true, tag: '[browser]', batch: { size: 20, interval: 300 }, - stackMode: 'condensed' + stackMode: 'condensed', + // Opt-in network logging + networkLogs: { + enabled: true, + captureFull: false, + bodies: { + request: true, + response: true, + maxBytes: 2048, + allowContentTypes: ['application/json', 'text/', 'application/x-www-form-urlencoded'], + prettyJson: true + } + } }); ``` @@ -67,6 +80,18 @@ interface BrowserEchoOptions { // server-side truncate?: number; // default: 10_000 chars (Vite) fileLog?: { enabled?: boolean; dir?: string }; // Vite-only + // network capture (opt-in) + networkLogs?: { + enabled?: boolean; + captureFull?: boolean; + bodies?: { + request?: boolean; + response?: boolean; + maxBytes?: number; // default 2048 bytes + allowContentTypes?: string[]; // default ['application/json','text/','application/x-www-form-urlencoded'] + prettyJson?: boolean; // default true + }; + }; // default disabled } ``` diff --git a/packages/core/build.config.ts b/packages/core/build.config.ts index 1173ce6..22fa24c 100644 --- a/packages/core/build.config.ts +++ b/packages/core/build.config.ts @@ -4,7 +4,8 @@ export default defineBuildConfig({ entries: [ './src/index', './src/client', - './src/types' + './src/types', + './src/worker' ], clean: true, declaration: true, diff --git a/packages/core/package.json b/packages/core/package.json index 0383867..2dada77 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@browser-echo/core", - "version": "1.0.1", + "version": "1.1.0-alpha.1", "type": "module", "sideEffects": false, "repository": { diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 1ec28da..f1f67fc 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -38,6 +38,23 @@ export function initBrowserEcho(opts: InitBrowserEchoOptions = {}) { ORIGINAL['info']?.(`${tag} forwarding console logs to ${route} (session ${session})`); } catch {} + // Optional: network capture (fetch + XHR) + const networkEnabled = !!opts.networkLogs?.enabled; + const networkFull = !!opts.networkLogs?.captureFull; + const bodiesCfg = opts.networkLogs?.bodies || {}; + const bodyReqEnabled = !!bodiesCfg.request; + const bodyResEnabled = !!bodiesCfg.response; + const bodyMaxBytes = (bodiesCfg.maxBytes ?? 2048) | 0; + const bodyPrettyJson = bodiesCfg.prettyJson !== false; + const bodyAllowed: string[] = (Array.isArray(bodiesCfg.allowContentTypes) && bodiesCfg.allowContentTypes.length) + ? bodiesCfg.allowContentTypes.map((s) => String(s).toLowerCase()) + : ['application/json', 'text/', 'application/x-www-form-urlencoded']; + if (networkEnabled) { + try { installFetchCapture(); } catch {} + try { installXhrCapture(); } catch {} + try { installWebSocketCapture(); } catch {} + } + function enqueue(entry: any) { queue.push(entry); if (queue.length >= batchSize) flush(); @@ -121,6 +138,329 @@ export function initBrowserEcho(opts: InitBrowserEchoOptions = {}) { return m ? `${m[1]}:${m[2]}:${m[3]}` : ''; } + function normalizeUrlString(input: any): string { + try { + if (typeof input === 'string') return input; + if (input && typeof input.url === 'string') return input.url; + if (input instanceof URL) return input.toString(); + return ''; + } catch { return ''; } + } + + function installFetchCapture() { + const orig = (window as any).fetch?.bind(window); + if (!orig) return; + (window as any).fetch = (input: any, init?: any) => { + const start = performance.now(); + const method = (init?.method || (typeof input === 'object' && input?.method) || 'GET').toUpperCase(); + const url = normalizeUrlString(input); + const baseLine = (status: number, durMs: number) => { + const statusText = isFinite(status as any) ? String(status) : 'ERR'; + return `[NETWORK] [${method}] [${url || '(request)'}] [${statusText}] [${durMs}ms]`; + }; + const getReqSnippet = (): Promise => { + if (!bodyReqEnabled) return Promise.resolve(''); + try { + // Prefer Request.clone() if available + if (input && typeof input === 'object' && typeof (input as any).clone === 'function') { + const req: any = input; + const headers = (req.headers && typeof req.headers.get === 'function') ? req.headers : null; + const ct = getHeader(headers, 'content-type') || (init?.headers ? getHeader(init?.headers, 'content-type') : ''); + if (!isAllowedContentType(ct)) return Promise.resolve(''); + return req.clone().text().then((txt: string) => formatBodySnippet(txt, ct)); + } + // Fallback to init.body as string/urlencoded + const ct = init?.headers ? getHeader(init.headers, 'content-type') : ''; + const body = init?.body; + if (typeof body === 'string') { + if (!ct || isAllowedContentType(ct) || isLikelyText(body)) return Promise.resolve(formatBodySnippet(body, ct)); + } else if (body && typeof (body as any).toString === 'function' && (body instanceof URLSearchParams)) { + const s = (body as URLSearchParams).toString(); + const reqCt = ct || 'application/x-www-form-urlencoded'; + if (isAllowedContentType(reqCt)) return Promise.resolve(formatBodySnippet(s, reqCt)); + } else if (body && typeof (body as any).size === 'number') { + const size = Number((body as any).size) | 0; + return Promise.resolve(`[binary: ${size} bytes]`); + } + } catch {} + return Promise.resolve(''); + }; + const getResSnippet = (res: any): Promise => { + if (!bodyResEnabled) return Promise.resolve(''); + try { + const headers = res?.headers; + const ct = getHeader(headers, 'content-type'); + if (!isAllowedContentType(ct)) return Promise.resolve(''); + if (res && typeof res.clone === 'function') { + try { + const clone = res.clone(); + if (clone && clone.body && typeof clone.body.getReader === 'function') { + return readStreamSnippet(clone, ct); + } + return clone.text().then((txt: string) => formatBodySnippet(txt, ct)); + } catch {} + } + } catch {} + return Promise.resolve(''); + }; + try { + const p = orig(input, init); + return Promise.resolve(p).then((res: any) => { + const dur = Math.max(0, Math.round(performance.now() - start)); + const statusNum = Number(res?.status ?? 0) | 0; + const ok = !!res?.ok; + const extra = networkFull ? ` [size:${Number(res?.headers?.get?.('content-length') || 0) | 0}]` : ''; + // Prepare body snippets asynchronously + Promise.all([getReqSnippet(), getResSnippet(res)]).then(([reqS, resS]) => { + let line = baseLine(statusNum, dur) + extra; + if (reqS) line += `\n req: ${reqS}`; + if (resS) line += `\n res: ${resS}`; + enqueue({ level: ok ? 'info' : 'warn', text: line, time: Date.now(), tag: '[network]' }); + }).catch(() => { + const line = baseLine(statusNum, dur) + extra; + enqueue({ level: ok ? 'info' : 'warn', text: line, time: Date.now(), tag: '[network]' }); + }); + return res; + }).catch((err: any) => { + const dur = Math.max(0, Math.round(performance.now() - start)); + Promise.resolve(getReqSnippet()).then((reqS) => { + let line = baseLine(0, dur); + line += ` fetch failed`; + if (reqS) line += `\n req: ${reqS}`; + enqueue({ level: 'warn', text: line, time: Date.now(), tag: '[network]' }); + }).catch(() => { + const line = baseLine(0, dur) + ' fetch failed'; + enqueue({ level: 'warn', text: line, time: Date.now(), tag: '[network]' }); + }); + throw err; + }); + } catch (err: any) { + const dur = Math.max(0, Math.round(performance.now() - start)); + let line = baseLine(0, dur) + ' fetch failed'; + enqueue({ level: 'warn', text: line, time: Date.now(), tag: '[network]' }); + throw err; + } + }; + } + + function installXhrCapture() { + const XHR = (window as any).XMLHttpRequest; + if (!XHR || !XHR.prototype) return; + const origOpen = XHR.prototype.open; + const origSend = XHR.prototype.send; + const origSetHeader = XHR.prototype.setRequestHeader; + XHR.prototype.open = function(method: string, url: string) { + try { (this as any).__be_method__ = String(method || 'GET').toUpperCase(); } catch {} + try { (this as any).__be_url__ = String(url || ''); } catch {} + return origOpen.apply(this, arguments as any); + } as any; + if (origSetHeader) { + XHR.prototype.setRequestHeader = function(name: string, value: string) { + try { + const k = String(name || '').toLowerCase(); + if (k === 'content-type') { (this as any).__be_req_ct__ = String(value || ''); } + } catch {} + return origSetHeader.apply(this, arguments as any); + } as any; + } + XHR.prototype.send = function() { + const start = performance.now(); + try { if (bodyReqEnabled) { (this as any).__be_req_body__ = arguments && arguments[0]; } } catch {} + const onEnd = () => { + try { + const dur = Math.max(0, Math.round(performance.now() - start)); + const method = (this as any).__be_method__ || 'GET'; + const u = (this as any).__be_url__ || ''; + const status = Number((this as any).status ?? 0) | 0; + const ok = status >= 200 && status < 400; + const extra = networkFull ? ` ready:${(this as any).readyState}` : ''; + let line = `[NETWORK] [${method}] [${u}] [${status || 'ERR'}] [${dur}ms]${extra}`; + // Bodies + if (bodyReqEnabled) { + try { + const reqCt = String((this as any).__be_req_ct__ || '').toLowerCase(); + const reqBody = (this as any).__be_req_body__; + const reqSnippet = formatRequestBodySync(reqBody, reqCt); + if (reqSnippet) line += `\n req: ${reqSnippet}`; + } catch {} + } + if (bodyResEnabled) { + try { + const resCt = String((this as any).getResponseHeader?.('Content-Type') || '').toLowerCase(); + if (isAllowedContentType(resCt)) { + let snippet = ''; + const rt = (this as any).responseType; + if (!rt || rt === 'text') { + try { snippet = formatBodySnippet(String((this as any).responseText || ''), resCt); } catch {} + } else if (rt === 'json') { + try { snippet = formatBodySnippet(JSON.stringify((this as any).response ?? null), 'application/json'); } catch {} + } + if (snippet) line += `\n res: ${snippet}`; + } + } catch {} + } + enqueue({ level: ok ? 'info' : 'warn', text: line, time: Date.now(), tag: '[network]' }); + } catch {} + try { + this.removeEventListener('loadend', onEnd); + this.removeEventListener('error', onEnd); + this.removeEventListener('abort', onEnd); + } catch {} + }; + try { this.addEventListener('loadend', onEnd); } catch {} + try { this.addEventListener('error', onEnd); } catch {} + try { this.addEventListener('abort', onEnd); } catch {} + return origSend.apply(this, arguments as any); + } as any; + } + + function installWebSocketCapture() { + const WS = (window as any).WebSocket; + if (!WS) return; + (window as any).WebSocket = new Proxy(WS, { + construct(Target: any, args: any[]) { + const url = normalizeUrlString(args?.[0]); + const start = performance.now(); + const socket = new Target(...args); + try { + socket.addEventListener('open', () => { + const dur = Math.max(0, Math.round(performance.now() - start)); + const text = `[NETWORK] [WS OPEN] [${url || '(ws)'}] [${dur}ms]`; + enqueue({ level: 'info', text, time: Date.now(), tag: '[network]' }); + }); + socket.addEventListener('close', (ev: any) => { + const dur = Math.max(0, Math.round(performance.now() - start)); + const code = Number(ev?.code ?? 0) | 0; + const reason = ev?.reason ? String(ev.reason) : ''; + const extra = reason ? `code:${code} reason:${reason}` : `code:${code}`; + const text = `[NETWORK] [WS CLOSE] [${url || '(ws)'}] [${dur}ms] ${extra}`; + enqueue({ level: code === 1000 ? 'info' : 'warn', text, time: Date.now(), tag: '[network]' }); + }); + socket.addEventListener('error', () => { + const dur = Math.max(0, Math.round(performance.now() - start)); + const text = `[NETWORK] [WS ERROR] [${url || '(ws)'}] [${dur}ms]`; + enqueue({ level: 'warn', text, time: Date.now(), tag: '[network]' }); + }); + } catch {} + return socket; + } + }); + } + + function getHeader(headers: any, name: string): string { + try { + if (!headers) return ''; + const key = String(name).toLowerCase(); + if (typeof headers.get === 'function') { + const v = headers.get(name) || headers.get(key) || ''; + return String(v || '').toLowerCase(); + } + if (Array.isArray(headers)) { + for (const [k, v] of headers) { + if (String(k).toLowerCase() === key) return String(v || '').toLowerCase(); + } + } + if (typeof headers === 'object') { + for (const k of Object.keys(headers)) { + if (k.toLowerCase() === key) return String((headers as any)[k] || '').toLowerCase(); + } + } + } catch {} + return ''; + } + + function isAllowedContentType(ct: string): boolean { + try { + const c = String(ct || '').toLowerCase(); + if (!c) return false; + for (const a of bodyAllowed) { + const al = String(a); + if (c.startsWith(al)) return true; + } + } catch {} + return false; + } + + function isLikelyText(s: string): boolean { + const trimmed = String(s || '').trim(); + if (!trimmed) return true; + if (trimmed.startsWith('{') || trimmed.startsWith('[')) return true; + return /^[\x09\x0A\x0D\x20-\x7E\u00A0-\uFFFF]*$/.test(trimmed); + } + + function formatBodySnippet(raw: string, contentType: string): string { + try { + let text = String(raw ?? ''); + const ct = String(contentType || '').toLowerCase(); + if (bodyPrettyJson && (ct.startsWith('application/json') || (text.trim().startsWith('{') || text.trim().startsWith('[')))) { + try { text = JSON.stringify(JSON.parse(text), null, 2); } catch {} + } + const enc = new TextEncoder(); + const bytes = enc.encode(text); + if (bytes.length <= bodyMaxBytes) return text; + const sliced = bytes.slice(0, Math.max(0, bodyMaxBytes)); + const dec = new TextDecoder(); + const shown = dec.decode(sliced); + const extra = bytes.length - sliced.length; + return `${shown}… (+${extra} bytes)`; + } catch { return ''; } + } + + function formatRequestBodySync(body: any, contentType: string): string { + try { + const ct = String(contentType || '').toLowerCase(); + if (!ct || !isAllowedContentType(ct)) { + if (typeof body === 'string' && isLikelyText(body)) return formatBodySnippet(body, ''); + return ''; + } + if (typeof body === 'string') return formatBodySnippet(body, ct); + if (body instanceof URLSearchParams) return formatBodySnippet(body.toString(), 'application/x-www-form-urlencoded'); + if (body && typeof body.size === 'number') return `[binary: ${Number(body.size) | 0} bytes]`; + } catch {} + return ''; + } + + async function readStreamSnippet(resClone: any, contentType: string): Promise { + try { + const reader = resClone.body?.getReader?.(); + if (!reader) return resClone.text().then((t: string) => formatBodySnippet(t, contentType)); + const chunks: Uint8Array[] = []; + let received = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + const v = value as Uint8Array; + if (received < bodyMaxBytes) { + const need = bodyMaxBytes - received; + chunks.push(need >= v.length ? v : v.slice(0, need)); + } + received += v.length; + if (received >= bodyMaxBytes) { + try { reader.cancel && reader.cancel(); } catch {} + break; + } + } + } + const merged = mergeUint8Arrays(chunks); + const dec = new TextDecoder(); + const shown = dec.decode(merged); + if (received <= bodyMaxBytes) return formatBodySnippet(shown, contentType); + const extra = received - merged.length; + return `${shown}… (+${extra} bytes)`; + } catch { + try { const t = await resClone.text(); return formatBodySnippet(t, contentType); } catch { return ''; } + } + } + + function mergeUint8Arrays(arrays: Uint8Array[]): Uint8Array { + const total = arrays.reduce((n, a) => n + a.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const a of arrays) { out.set(a, off); off += a.length; } + return out; + } + function randomId() { try { const arr = new Uint8Array(8); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0a97469..e95a50e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,2 +1,4 @@ export * from './types'; export { initBrowserEcho } from './client'; +// Worker runtime is internal for now; not part of public API surface +// export { initWorkerEcho } from './worker'; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e8836a6..9da79aa 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -7,4 +7,15 @@ export interface InitBrowserEchoOptions { tag?: string; batch?: { size?: number; interval?: number }; stackMode?: 'full' | 'condensed' | 'none'; + networkLogs?: { + enabled?: boolean; + captureFull?: boolean; + bodies?: { + request?: boolean; + response?: boolean; + maxBytes?: number; + allowContentTypes?: string[]; + prettyJson?: boolean; + }; + }; } diff --git a/packages/core/src/worker.ts b/packages/core/src/worker.ts new file mode 100644 index 0000000..1104e59 --- /dev/null +++ b/packages/core/src/worker.ts @@ -0,0 +1,47 @@ +/** + * Dev-only worker console capture. Safe for Dedicated/Shared/Service Workers. + * Usage inside worker scope: + * importScripts('/path/to/worker.js'); + * initWorkerEcho({ route: '/__client-logs' }); + */ +export function initWorkerEcho(options: { route?: `/${string}`; include?: Array<'log' | 'info' | 'warn' | 'error' | 'debug'>; batch?: { size?: number; interval?: number } } = {}) { + // @ts-expect-error WorkerGlobalScope + const selfRef: any = (typeof self !== 'undefined' ? self : undefined); + if (!selfRef) return; + if (selfRef.__worker_echo_installed__) return; + selfRef.__worker_echo_installed__ = true; + + const route = options.route || '/__client-logs'; + const include = options.include || ['log','info','warn','error','debug']; + const batchSize = options.batch?.size ?? 20; + const batchInterval = options.batch?.interval ?? 300; + const session = (() => { + try { const a = new Uint8Array(8); (selfRef.crypto||crypto).getRandomValues(a); return Array.from(a).map((b) => b.toString(16).padStart(2,'0')).join(''); } catch { return String(Math.random()).slice(2,10); } + })(); + + const queue: any[] = []; + let timer: any = null; + function enqueue(entry: any) { + queue.push(entry); + if (queue.length >= batchSize) flush(); else if (!timer) timer = setTimeout(flush, batchInterval); + } + function flush() { + if (timer) { clearTimeout(timer); timer = null; } + if (!queue.length) return; + const entries = queue.splice(0, queue.length); + const payload = JSON.stringify({ sessionId: session, entries }); + try { (selfRef.navigator && (selfRef.navigator as any).sendBeacon) ? (selfRef.navigator as any).sendBeacon(route, new Blob([payload], { type: 'application/json' })) : fetch(route, { method: 'POST', headers: { 'content-type': 'application/json' }, body: payload, keepalive: true as any, cache: 'no-store' as any }).catch(() => {}); } catch {} + } + + const ORIGINAL: Record void> = {} as any; + for (const level of include) { + const orig = selfRef.console && selfRef.console[level] ? selfRef.console[level].bind(selfRef.console) : (selfRef.console && selfRef.console.log ? selfRef.console.log.bind(selfRef.console) : (() => {})); + ORIGINAL[level] = orig; + selfRef.console[level] = (...args: any[]) => { + try { enqueue({ level, text: args.map((v: any) => { try { return typeof v === 'string' ? v : JSON.stringify(v); } catch { try { return String(v) } catch { return '[Unserializable]' } } }).join(' '), time: Date.now(), tag: '[worker]' }); } catch {} + try { orig(...args); } catch {} + }; + } +} + + diff --git a/packages/mcp/README.md b/packages/mcp/README.md index e30be51..b76ca74 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -196,6 +196,9 @@ get_logs({ sinceMs: Date.now() - 300000 }) // Limit results and exclude stack traces get_logs({ level: ['error'], limit: 10, includeStack: false }) + +// Filter by stream tag: '[browser]' | '[network]' | '[worker]' +get_logs({ tag: '[network]' }) ``` **Parameters:** @@ -205,6 +208,7 @@ get_logs({ level: ['error'], limit: 10, includeStack: false }) - `limit?: number` — Maximum entries to return (default: `1000`, max: `5000`) - `contains?: string` — Filter by substring in log text - `sinceMs?: number` — Only logs with timestamp >= sinceMs + - `tag?: '[browser]' | '[network]' | '[worker]'` — Filter by stream tag **Returns:** Formatted text suitable for AI analysis + structured JSON data @@ -405,6 +409,8 @@ Configure the MCP server behavior with these environment variables: - `BROWSER_ECHO_INGEST_PORT=5179` — Force a fixed ingest port in stdio mode (default: 5179) - `BROWSER_ECHO_SUPPRESS_TERMINAL=1` — Force suppress terminal output when MCP is forwarding logs - `BROWSER_ECHO_SUPPRESS_TERMINAL=0` — Force show terminal output even when MCP is active + - `BROWSER_ECHO_FILE_LOG=true` — Enable ingest-side file logging + - `BROWSER_ECHO_SPLIT_LOGS=true` — Split logs to `logs/frontend/*` --- diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 8b8ae6c..e39c2c4 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@browser-echo/mcp", - "version": "1.0.1", + "version": "1.1.0-alpha.1", "private": false, "type": "module", "main": "dist/index.mjs", diff --git a/packages/mcp/src/schemas/logs.ts b/packages/mcp/src/schemas/logs.ts index 9ee9b66..e17a797 100644 --- a/packages/mcp/src/schemas/logs.ts +++ b/packages/mcp/src/schemas/logs.ts @@ -7,7 +7,8 @@ export const GetLogsArgs = { includeStack: z.boolean().optional().default(false).describe('Include stack traces in text view'), limit: z.number().int().min(1).max(5000).optional().describe('Max number of entries to return'), contains: z.string().optional().describe('Substring filter on entry.text'), - sinceMs: z.number().nonnegative().optional().describe('Only entries with time >= sinceMs') + sinceMs: z.number().nonnegative().optional().describe('Only entries with time >= sinceMs'), + tag: z.enum(['[browser]','[network]','[worker]']).optional().describe('Filter by stream tag') } satisfies z.ZodRawShape; export const ClearLogsArgs = { diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index e336184..25234d3 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -1,5 +1,5 @@ import { createServer as createNodeServer } from 'node:http'; -import { writeFileSync, rmSync, renameSync } from 'node:fs'; +import { writeFileSync, rmSync, renameSync, appendFileSync, mkdirSync } from 'node:fs'; import { join as joinPath } from 'node:path'; import { randomUUID } from 'node:crypto'; @@ -339,11 +339,30 @@ function writeProjectJson(host: string, port: number, route: `/${string}`) { /** Create log ingest routes that can be attached to any H3 app */ function createLogIngestRoutes(store: LogStore, logsRoute: `/${string}`) { const router = createRouter(); + const FILE_LOG_ENABLED = String(process.env.BROWSER_ECHO_FILE_LOG || '').toLowerCase() === 'true'; + const SPLIT_LOGS = String(process.env.BROWSER_ECHO_SPLIT_LOGS || '').toLowerCase() === 'true'; + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const FRONTEND_DIR = SPLIT_LOGS ? 'logs/frontend' : 'logs'; + const FRONTEND_FILE = joinPath(FRONTEND_DIR, `dev-${stamp}.log`); + if (FILE_LOG_ENABLED) { try { mkdirSync(FRONTEND_DIR, { recursive: true }); } catch {} } // Log diagnostics (GET) → text/plain router.get(logsRoute, defineEventHandler(async (event) => { - const q = getQuery(event) as { session?: string }; - const text = store.toText(q?.session ? String(q.session).slice(0, 8) : undefined); + const q = getQuery(event) as { session?: string, tag?: '[browser]' | '[network]' | '[worker]' }; + let items = store.snapshot(q?.session ? String(q.session).slice(0, 8) : undefined); + if (q?.tag) items = items.filter(e => (e.tag || '[browser]') === q.tag); + const text = items.map((e) => { + const sid = (e.sessionId || 'anon').slice(0, 8); + const lvl = (e.level || 'log').toUpperCase(); + const tag = e.tag || '[browser]'; + let line = `${tag} [${sid}] ${lvl}: ${e.text}`; + if (e.source) line += ` (${e.source})`; + if (e.stack && e.stack.trim().length) { + const indented = e.stack.split(/\r?\n/g).map((l) => (l.length ? ` ${l}` : l)).join('\n'); + return `${line}\n${indented}`; + } + return line; + }).join('\n'); try { event.node.res.setHeader('content-type', 'text/plain; charset=utf-8'); event.node.res.setHeader('cache-control', 'no-store'); @@ -361,17 +380,29 @@ function createLogIngestRoutes(store: LogStore, logsRoute: `/${string}`) { return 'invalid payload'; } const sid = String(payload.sessionId ?? 'anon'); - for (const entry of payload.entries as Array<{ level: BrowserLogLevel | string; text: string; time?: number; stack?: string; source?: string; }>) { + for (const entry of payload.entries as Array<{ level: BrowserLogLevel | string; text: string; time?: number; stack?: string; source?: string; tag?: string; }>) { const level = normalizeLevel(entry.level); - store.append({ + const entryOut = { sessionId: sid, level, text: String(entry.text ?? ''), time: entry.time, source: entry.source, stack: entry.stack, - tag: '[browser]' - }); + tag: entry.tag || '[browser]' + } as const; + store.append(entryOut); + if (FILE_LOG_ENABLED) { + const time = new Date().toISOString(); + let line = `${entryOut.tag} [${sid.slice(0,8)}] ${entryOut.level.toUpperCase()}: ${entryOut.text}`; + if (entryOut.source) line += ` (${entryOut.source})`; + const payload = [`[${time}] ${line}`]; + if (entryOut.stack && entryOut.stack.trim().length) { + const indented = entryOut.stack.split(/\r?\n/g).map(l => l ? ` ${l}` : l).join('\n'); + payload.push(indented); + } + try { appendFileSync(FRONTEND_FILE, payload.join('\n') + '\n'); } catch {} + } } setResponseStatus(event, 204); return ''; diff --git a/packages/mcp/src/tools/getLogs.ts b/packages/mcp/src/tools/getLogs.ts index 9daf63b..534d268 100644 --- a/packages/mcp/src/tools/getLogs.ts +++ b/packages/mcp/src/tools/getLogs.ts @@ -18,7 +18,8 @@ export function registerGetLogsTool(ctx: McpToolContext) { includeStack = false, limit = 1000, contains, - sinceMs + sinceMs, + tag } = safeArgs; const validSession = validateSessionId(session); @@ -29,6 +30,7 @@ export function registerGetLogsTool(ctx: McpToolContext) { if (validSince) items = items.filter(e => !e.time || e.time >= validSince); if (level?.length) items = items.filter(e => level.includes(e.level)); if (contains) items = items.filter(e => (e.text || '').includes(contains)); + if (tag) items = items.filter(e => (e.tag || '[browser]') === tag); const final = includeStack ? items : items.map(e => ({ ...e, stack: '' })); const limited = limit && final.length > limit ? final.slice(-limit) : final; diff --git a/packages/mcp/test/http.smoke.test.ts b/packages/mcp/test/http.smoke.test.ts index 07c0635..9da555d 100644 --- a/packages/mcp/test/http.smoke.test.ts +++ b/packages/mcp/test/http.smoke.test.ts @@ -37,13 +37,14 @@ describe('@browser-echo/mcp streamable HTTP transport (smoke)', () => { }); it('ingests a browser log and shows in GET diagnostics', async () => { - const payload = { sessionId: 'feedf00d', entries: [{ level: 'warn', text: 'http smoke' }] }; + const payload = { sessionId: 'feedf00d', entries: [{ level: 'warn', text: 'http smoke', tag: '[network]' }] }; const ingest = await httpPost('/__client-logs', payload); expect(ingest.status).toBe(204); const diag = await httpGet('/__client-logs'); expect(diag.status).toBe(200); const body = await diag.text(); + expect(body).toContain('[network]'); expect(body).toContain('http smoke'); }); diff --git a/packages/next/README.md b/packages/next/README.md index b66a223..2d9f6cd 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -59,6 +59,18 @@ interface BrowserEchoScriptProps { showSource?: boolean; // default: true (when available) // batching batch?: { size?: number; interval?: number }; // default: 20 / 300ms + // Opt-in network capture (fetch/XHR/WS) + networkLogs?: { + enabled?: boolean; + captureFull?: boolean; + bodies?: { + request?: boolean; + response?: boolean; + maxBytes?: number; // default 2048 bytes + allowContentTypes?: string[]; // default ['application/json','text/','application/x-www-form-urlencoded'] + prettyJson?: boolean; // default true + }; + }; } ``` @@ -100,6 +112,10 @@ export default function RootLayout({ children }: { children: ReactNode }) { stackMode="condensed" showSource={true} batch={{ size: 10, interval: 500 }} + networkLogs={{ + enabled: true, + bodies: { request: true, response: true, maxBytes: 2048 } + }} /> )} @@ -202,7 +218,7 @@ Discovery order: ### Environment Variables -- `BROWSER_ECHO_MCP_URL=http://127.0.0.1:5179/mcp` — Set MCP server URL (base URL is derived automatically) +- `BROWSER_ECHO_MCP_URL=http://127.0.0.1:5179/mcp` — Set MCP server URL (base URL is derived automatically). When set, the route will forward; terminal printing is suppressed unless explicitly disabled. - `BROWSER_ECHO_SUPPRESS_TERMINAL=1` — Force suppress terminal output - `BROWSER_ECHO_SUPPRESS_TERMINAL=0` — Force show terminal output even when MCP is active diff --git a/packages/next/package.json b/packages/next/package.json index 60c4d71..7fbb204 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "@browser-echo/next", - "version": "1.0.1", + "version": "1.1.0-alpha.1", "type": "module", "sideEffects": false, "repository": { diff --git a/packages/next/src/BrowserEchoScript.tsx b/packages/next/src/BrowserEchoScript.tsx index adf2513..779ebfe 100644 --- a/packages/next/src/BrowserEchoScript.tsx +++ b/packages/next/src/BrowserEchoScript.tsx @@ -13,6 +13,17 @@ export interface BrowserEchoScriptProps { stackMode?: 'none' | 'condensed' | 'full'; showSource?: boolean; batch?: { size?: number; interval?: number }; + networkLogs?: { + enabled?: boolean; + captureFull?: boolean; + bodies?: { + request?: boolean; + response?: boolean; + maxBytes?: number; + allowContentTypes?: string[]; + prettyJson?: boolean; + }; + }; } export default function BrowserEchoScript(props: BrowserEchoScriptProps): JSX.Element { @@ -30,6 +41,8 @@ export default function BrowserEchoScript(props: BrowserEchoScriptProps): JSX.El const showSource = props.showSource ?? true; const batchSize = props.batch?.size ?? 20; const batchInterval = props.batch?.interval ?? 300; + const netEnabled = props.networkLogs?.enabled === true; + const netFull = !!props.networkLogs?.captureFull; const code = ` (function(){ @@ -39,13 +52,15 @@ export default function BrowserEchoScript(props: BrowserEchoScriptProps): JSX.El var ROUTE=${JSON.stringify(route)}, INCLUDE=${include}, PRESERVE=${JSON.stringify(preserve)}, TAG=${JSON.stringify(tag)}; var STACK_MODE=${JSON.stringify(stackMode)}, SHOW_SOURCE=${JSON.stringify(showSource)}; var BATCH_SIZE=${JSON.stringify(batchSize)}, BATCH_INTERVAL=${JSON.stringify(batchInterval)}; + var NET_ENABLED=${JSON.stringify(netEnabled)}, NET_FULL=${JSON.stringify(netFull)}; + var NET_BODY=${JSON.stringify(props.networkLogs?.bodies || {})}; var SESSION=(function(){try{var a=new Uint8Array(8);crypto.getRandomValues(a);return Array.from(a).map(b=>b.toString(16).padStart(2,'0')).join('')}catch{return String(Math.random()).slice(2,10)}})(); var q=[],t=null; function enqueue(e){q.push(e); if(q.length>=BATCH_SIZE){flush()} else if(!t){t=setTimeout(flush,BATCH_INTERVAL)}} function flush(){ if(t){clearTimeout(t); t=null} if(!q.length) return; var p=JSON.stringify({sessionId:SESSION,entries:q.splice(0,q.length)}); try{ if(navigator.sendBeacon) navigator.sendBeacon(ROUTE,new Blob([p],{type:'application/json'})); - else fetch(ROUTE,{method:'POST',headers:{'content-type':'application/json'},body:p,keepalive:true,cache:'no-store'}).catch(()=>{}); }catch(_){} + else fetch(ROUTE,{method:'POST',headers:{'content-type':'application/json'},body:p,keepalive:true,cache:'no-store'}).catch(()=>{}); }catch(_){ } } document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='hidden') flush()}); addEventListener('pagehide', flush); addEventListener('beforeunload', flush); @@ -59,16 +74,16 @@ export default function BrowserEchoScript(props: BrowserEchoScriptProps): JSX.El function captureStack(){ if(STACK_MODE === 'none') return ''; try{ - var e=new Error(), raw=e.stack||'', lines=raw.split('\\n').slice(1); + var e=new Error(), raw=e.stack||'', lines=raw.split('\n').slice(1); var filtered = lines.filter(l=>!/browser-echo|captureStack|safeFormat|enqueue|flush/.test(l)); if(STACK_MODE === 'condensed') { // Return only the first meaningful line for condensed mode - return filtered.slice(0, 1).join('\\n'); + return filtered.slice(0, 1).join('\n'); } - return filtered.join('\\n'); + return filtered.join('\n'); }catch{return ''} } - function parseSource(stack){ if(!stack) return ''; var m=stack.match(/\\(?((?:file:\\/\\/|https?:\\/\\/|\\/)[^) \\n]+):(\\d+):(\\d+)\\)?/); return m? (m[1]+':'+m[2]+':'+m[3]) : '' } + function parseSource(stack){ if(!stack) return ''; var m=stack.match(/\(?((?:file:\/\/|https?:\/\/|\/)[^) \n]+):(\d+):(\d+)\)?/); return m? (m[1]+':'+m[2]+':'+m[3]) : '' } var ORIGINAL={}; for (var i=0;i= v.length ? v : v.slice(0, need)); received += v.length; if (received >= max) { try{ reader.cancel && reader.cancel() }catch{} break; } } } var totalLen=chunks.reduce((n,a)=>n+a.length,0); var out=new Uint8Array(totalLen); var off=0; for (var i=0;i{ try{ var dur = Math.max(0, Math.round(performance.now()-start)); var method = this.__be_method__ || 'GET'; var u = this.__be_url__ || ''; var status = Number(this.status||0)|0; var ok = status >= 200 && status < 400; var extra = NET_FULL ? ('ready:'+this.readyState) : ''; var line = '[NETWORK] ['+method+'] ['+u+'] ['+(status||'ERR')+'] ['+dur+'ms]'+(extra?(' '+extra):''); enqueue({ level: ok ? 'info' : 'warn', text: line, time: Date.now(), tag: '[network]' }); } catch {} try { this.removeEventListener('loadend', onEnd); this.removeEventListener('error', onEnd); this.removeEventListener('abort', onEnd); } catch {} }; + try { this.addEventListener('loadend', onEnd); } catch {} + try { this.addEventListener('error', onEnd); } catch {} + try { this.addEventListener('abort', onEnd); } catch {} + return _send.apply(this, arguments); + } + } + } catch {} + try { + var WS = window.WebSocket; + if (WS) { + // @ts-ignore + window.WebSocket = new Proxy(WS, { + construct: function(Target, args) { + var url = normUrl(args && args[0]); + var start = performance.now(); + // @ts-ignore + var socket = new Target(...args); + try { + socket.addEventListener('open', function(){ + var dur = Math.max(0, Math.round(performance.now() - start)); + var line = '[NETWORK] [WS OPEN] ['+(url||'(ws)')+'] ['+dur+'ms]'; + enqueue({ level: 'info', text: line, time: Date.now(), tag: '[network]' }); + }); + socket.addEventListener('close', function(ev){ + var dur = Math.max(0, Math.round(performance.now() - start)); + var code = Number(ev && ev.code || 0) | 0; + var reason = ev && ev.reason ? String(ev.reason) : ''; + var extra = reason ? ('code:'+code+' reason:'+reason) : ('code:'+code); + var line = '[NETWORK] [WS CLOSE] ['+(url||'(ws)')+'] ['+dur+'ms] '+extra; + enqueue({ level: code === 1000 ? 'info' : 'warn', text: line, time: Date.now(), tag: '[network]' }); + }); + socket.addEventListener('error', function(){ + var dur = Math.max(0, Math.round(performance.now() - start)); + var line = '[NETWORK] [WS ERROR] ['+(url||'(ws)')+'] ['+dur+'ms]'; + enqueue({ level: 'warn', text: line, time: Date.now(), tag: '[network]' }); + }); + } catch {} + return socket; + } + }); + } + } catch {} + } })(); `.trim(); diff --git a/packages/next/src/route.ts b/packages/next/src/route.ts index f0cd05a..1f91327 100644 --- a/packages/next/src/route.ts +++ b/packages/next/src/route.ts @@ -4,7 +4,7 @@ import { NextResponse } from 'next/server'; // Simplified: resolve MCP from project-local JSON once; no fallback export type BrowserLogLevel = 'log' | 'info' | 'warn' | 'error' | 'debug'; -type Entry = { level: BrowserLogLevel | string; text: string; time?: number; stack?: string; source?: string; }; +type Entry = { level: BrowserLogLevel | string; text: string; time?: number; stack?: string; source?: string; tag?: string }; type Payload = { sessionId?: string; entries: Entry[] }; export const runtime = 'nodejs'; @@ -35,12 +35,20 @@ export async function POST(req: NextRequest) { } // Dynamically decide whether to print to terminal - const shouldPrint = !mcp.url; + // Only suppress when MCP URL is explicitly configured via env var + const envMcp = (() => { + const raw = process.env.BROWSER_ECHO_MCP_URL; + if (!raw) return ''; + const s = String(raw).trim().toLowerCase(); + if (!s || s === 'undefined' || s === 'null' || s === 'false' || s === '0') return ''; + return String(raw).trim(); + })(); + const shouldPrint = !envMcp; const sid = (payload.sessionId ?? 'anon').slice(0, 8); for (const entry of payload.entries) { const level = norm(entry.level); - let line = `[browser] [${sid}] ${level.toUpperCase()}: ${entry.text}`; + let line = `${entry.tag || '[browser]'} [${sid}] ${level.toUpperCase()}: ${entry.text}`; if (entry.source) line += ` (${entry.source})`; if (shouldPrint) print(level, color(level, line)); if (entry.stack && shouldPrint) print(level, dim(indent(entry.stack, ' '))); diff --git a/packages/nuxt/README.md b/packages/nuxt/README.md index 18a45fd..9170245 100644 --- a/packages/nuxt/README.md +++ b/packages/nuxt/README.md @@ -62,6 +62,18 @@ interface BrowserEchoNuxtOptions { tag?: string; // default: '[browser]' batch?: { size?: number; interval?: number }; // default: 20 / 300ms stackMode?: 'full' | 'condensed' | 'none'; // default: 'condensed' + // Opt-in network capture (fetch/XHR/WS) + networkLogs?: { + enabled?: boolean; + captureFull?: boolean; + bodies?: { + request?: boolean; + response?: boolean; + maxBytes?: number; // default 2048 bytes + allowContentTypes?: string[]; // default ['application/json','text/','application/x-www-form-urlencoded'] + prettyJson?: boolean; // default true + }; + }; } ``` @@ -92,6 +104,11 @@ export default defineNuxtConfig({ batch: { size: 10, // Send after 10 logs interval: 500 // Or after 500ms + }, + // Enable network body snippets (opt-in) + networkLogs: { + enabled: true, + bodies: { request: true, response: true, maxBytes: 2048 } } } }); @@ -114,7 +131,7 @@ Discovery order: ### Environment Variables -- `BROWSER_ECHO_MCP_URL=http://127.0.0.1:5179/mcp` — Set MCP server URL (base URL is derived automatically) +- `BROWSER_ECHO_MCP_URL=http://127.0.0.1:5179/mcp` — Set MCP server URL (base URL is derived automatically). When set, the handler forwards; terminal printing is suppressed unless explicitly disabled. - `BROWSER_ECHO_SUPPRESS_TERMINAL=1` — Force suppress terminal output - `BROWSER_ECHO_SUPPRESS_TERMINAL=0` — Force show terminal output even when MCP is active diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index cb1925c..a874919 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -1,6 +1,6 @@ { "name": "@browser-echo/nuxt", - "version": "1.0.1", + "version": "1.1.0-alpha.1", "type": "module", "sideEffects": false, "repository": { diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 1d6c00c..0c68642 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -8,6 +8,17 @@ export interface NuxtBrowserEchoOptions { tag?: string; batch?: { size?: number; interval?: number }; stackMode?: 'full' | 'condensed' | 'none'; + networkLogs?: { + enabled?: boolean; + captureFull?: boolean; + bodies?: { + request?: boolean; + response?: boolean; + maxBytes?: number; + allowContentTypes?: string[]; + prettyJson?: boolean; + }; + }; } const module: any = defineNuxtModule({ @@ -19,7 +30,8 @@ const module: any = defineNuxtModule({ preserveConsole: true, tag: '[browser]', batch: { size: 20, interval: 300 }, - stackMode: 'condensed' + stackMode: 'condensed', + networkLogs: { enabled: false, captureFull: false, bodies: { request: false, response: false, maxBytes: 2048, allowContentTypes: ['application/json','text/','application/x-www-form-urlencoded'], prettyJson: true } } }, setup(options, nuxt) { if (!nuxt.options.dev || options.enabled === false) return; @@ -42,7 +54,8 @@ export default defineNuxtPlugin(() => { preserveConsole: options.preserveConsole, tag: options.tag, batch: options.batch, - stackMode: options.stackMode + stackMode: options.stackMode, + networkLogs: options.networkLogs })}); } }); diff --git a/packages/nuxt/src/runtime/server/handler.ts b/packages/nuxt/src/runtime/server/handler.ts index 81e86d3..198f192 100644 --- a/packages/nuxt/src/runtime/server/handler.ts +++ b/packages/nuxt/src/runtime/server/handler.ts @@ -3,7 +3,7 @@ import { defineEventHandler, readBody, setResponseStatus } from 'h3'; // Simplified: resolve MCP from project-local JSON once; no fallback type Level = 'log' | 'info' | 'warn' | 'error' | 'debug'; -type Entry = { level: Level | string; text: string; time?: number; stack?: string; source?: string; }; +type Entry = { level: Level | string; text: string; time?: number; stack?: string; source?: string; tag?: string }; type Payload = { sessionId?: string; entries: Entry[] }; export default defineEventHandler(async (event) => { @@ -33,13 +33,13 @@ export default defineEventHandler(async (event) => { } catch {} } - // Suppress when forwarding active - const shouldPrint = !mcp.url; + // Suppress only when explicitly configured via env var + const shouldPrint = !process.env.BROWSER_ECHO_MCP_URL; const sid = (payload.sessionId ?? 'anon').slice(0, 8); for (const entry of payload.entries) { const level = norm(entry.level); - let line = `[browser] [${sid}] ${level.toUpperCase()}: ${entry.text}`; + let line = `${entry.tag || '[browser]'} [${sid}] ${level.toUpperCase()}: ${entry.text}`; if (entry.source) line += ` (${entry.source})`; if (shouldPrint) print(level, color(level, line)); if (entry.stack && shouldPrint) print(level, dim(indent(entry.stack, ' '))); diff --git a/packages/react/README.md b/packages/react/README.md index 221819e..c6de693 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -69,6 +69,25 @@ ReactDOM.createRoot(document.getElementById('root')!).render(); You need a development server endpoint that accepts POST requests at `/__client-logs` and prints the received logs to your terminal. The React provider only handles the client side. +Note: Network capture (fetch/XHR/WS) is available via `@browser-echo/core` and framework providers (Vite, Next, Nuxt). This React (non-Vite) package does not inject network capture on its own. +If you use `initBrowserEcho` directly, you can also enable network body snippets via core options: + +```ts +initBrowserEcho({ + route: '/__client-logs', + networkLogs: { + enabled: true, + bodies: { + request: true, + response: true, + maxBytes: 2048, + allowContentTypes: ['application/json','text/','application/x-www-form-urlencoded'], + prettyJson: true + } + } +}); +``` + Example Express.js endpoint: ```js diff --git a/packages/react/package.json b/packages/react/package.json index e4420a2..5ceb575 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@browser-echo/react", - "version": "1.0.1", + "version": "1.1.0-alpha.1", "type": "module", "sideEffects": false, "repository": { diff --git a/packages/vite/README.md b/packages/vite/README.md index 314b974..90abf19 100644 --- a/packages/vite/README.md +++ b/packages/vite/README.md @@ -20,6 +20,8 @@ This package provides a Vite plugin that includes dev middleware and a virtual m - Colorized terminal output - Full stack trace support with multiple modes - Works with `index.html` or server-side rendered apps + - Optional network capture (opt-in): fetch, XMLHttpRequest, WebSocket + - Optional request/response body snippets (opt-in) with safe truncation ## Installation @@ -131,7 +133,7 @@ interface BrowserEchoViteOptions { showSource?: boolean; // default: true batch?: { size?: number; interval?: number }; // default: 20 / 300ms truncate?: number; // default: 10_000 chars - fileLog?: { enabled?: boolean; dir?: string }; // default: disabled + fileLog?: { enabled?: boolean; dir?: string; split?: boolean }; // default: disabled mcp?: { url?: string; // MCP server base URL (auto-discovered if not set) routeLogs?: `/${string}`; // MCP logs route (default: '/__client-logs') @@ -140,6 +142,17 @@ interface BrowserEchoViteOptions { }; discoverMcp?: boolean; // Enable MCP auto-discovery (default: true) discoveryRefreshMs?: number; // Discovery refresh interval (default: 30000) + networkLogs?: { + enabled?: boolean; + captureFull?: boolean; + bodies?: { + request?: boolean; + response?: boolean; + maxBytes?: number; // default 2048 bytes + allowContentTypes?: string[]; // default ['application/json','text/','application/x-www-form-urlencoded'] + prettyJson?: boolean; // default true + }; + }; // default disabled } ``` @@ -170,6 +183,31 @@ browserEcho({ }) ``` +### Network body snippets (opt-in) + +```ts +browserEcho({ + networkLogs: { + enabled: true, + bodies: { + request: true, + response: true, + maxBytes: 2048, + allowContentTypes: ['application/json','text/','application/x-www-form-urlencoded'], + prettyJson: true + } + } +}) +``` + +Output example: + +``` +[NETWORK] [POST] [/api/users] [200] [18ms] + req: {"name":"Ada"} + res: { "id": 1, "name": "Ada" } +``` + ### Disable MCP ```ts @@ -184,6 +222,8 @@ browserEcho({ - `BROWSER_ECHO_MCP_URL=http://127.0.0.1:5179/mcp` — Set MCP server URL - `BROWSER_ECHO_SUPPRESS_TERMINAL=1` — Force suppress terminal output - `BROWSER_ECHO_SUPPRESS_TERMINAL=0` — Force show terminal output + - `BROWSER_ECHO_FILE_LOG=true` — Enable MCP-side file logging (ingest server) + - `BROWSER_ECHO_SPLIT_LOGS=true` — Split logs into logs/frontend vs combined #### Discovery behavior @@ -202,6 +242,26 @@ browserEcho({ }) ``` +### Split file logs by tag + +Write separate files under per-tag subdirectories (e.g. `logs/network/dev-*.log`): + +```ts +browserEcho({ + fileLog: { + enabled: true, + dir: 'logs', + split: true + }, + networkLogs: { enabled: true } +}) +``` + +This produces, for example: +- `logs/browser/dev-YYYY-MM-DDTHH-MM-SS.log` +- `logs/network/dev-YYYY-MM-DDTHH-MM-SS.log` +- `logs/worker/dev-YYYY-MM-DDTHH-MM-SS.log` + ## How it works The plugin: diff --git a/packages/vite/package.json b/packages/vite/package.json index ba1c06a..3477a3d 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -1,6 +1,6 @@ { "name": "@browser-echo/vite", - "version": "1.0.1", + "version": "1.1.0-alpha.1", "type": "module", "sideEffects": false, "repository": { diff --git a/packages/vite/src/index.ts b/packages/vite/src/index.ts index 143362e..5d8be87 100644 --- a/packages/vite/src/index.ts +++ b/packages/vite/src/index.ts @@ -3,6 +3,8 @@ import ansis from 'ansis'; import type { BrowserLogLevel } from '@browser-echo/core'; import { mkdirSync, appendFileSync, existsSync, readFileSync } from 'node:fs'; import { join as joinPath, dirname } from 'node:path'; +import { createRequire } from 'node:module'; +const __require = createRequire(import.meta.url); export interface BrowserLogsToTerminalOptions { enabled?: boolean; @@ -16,8 +18,19 @@ export interface BrowserLogsToTerminalOptions { stackMode?: 'none' | 'condensed' | 'full'; batch?: { size?: number; interval?: number }; truncate?: number; - fileLog?: { enabled?: boolean; dir?: string }; + fileLog?: { enabled?: boolean; dir?: string; split?: boolean }; mcp?: { url?: string; routeLogs?: `/${string}`; suppressTerminal?: boolean; headers?: Record }; + networkLogs?: { + enabled?: boolean; + captureFull?: boolean; + bodies?: { + request?: boolean; + response?: boolean; + maxBytes?: number; + allowContentTypes?: string[]; + prettyJson?: boolean; + }; + }; } type ResolvedOptions = Required> & { @@ -38,8 +51,9 @@ const DEFAULTS: ResolvedOptions = { stackMode: 'condensed', batch: { size: 20, interval: 300 }, truncate: 10_000, - fileLog: { enabled: false, dir: 'logs/frontend' }, - mcp: { url: '', routeLogs: '/__client-logs', suppressTerminal: true, headers: {}, suppressProvided: false } + fileLog: { enabled: false, dir: 'logs/frontend', split: false }, + mcp: { url: '', routeLogs: '/__client-logs', suppressTerminal: true, headers: {}, suppressProvided: false }, + networkLogs: { enabled: true, captureFull: false } }; export default function browserEcho(opts: BrowserLogsToTerminalOptions = {}): any { @@ -94,8 +108,9 @@ function normalizeMcpBaseUrl(input: string | undefined): string { function attachMiddleware(server: any, options: ResolvedOptions) { const sessionStamp = new Date().toISOString().replace(/[:.]/g, '-'); - const logFilePath = joinPath(options.fileLog.dir, `dev-${sessionStamp}.log`); - if (options.fileLog.enabled) { try { mkdirSync(dirname(logFilePath), { recursive: true }); } catch {} } + const baseLogDir = options.fileLog.dir; + const defaultFilePath = joinPath(baseLogDir, `dev-${sessionStamp}.log`); + if (options.fileLog.enabled && !options.fileLog.split) { try { mkdirSync(dirname(defaultFilePath), { recursive: true }); } catch {} } // Simplified MCP ingest resolution: project JSON once; no fallback; retry on failure let resolvedBase = ''; @@ -121,24 +136,18 @@ function attachMiddleware(server: any, options: ResolvedOptions) { function readProjectJson(): { url: string; route?: `/${string}` } | null { try { - let dir = process.cwd(); - for (let depth = 0; depth < 10; depth++) { - const p = joinPath(dir, '.browser-echo-mcp.json'); - if (existsSync(p)) { - const raw = readFileSync(p, 'utf-8'); - let data: any; - try { data = JSON.parse(raw); } - catch (err: any) { - try { server.config.logger.warn(`${options.tag} failed to parse .browser-echo-mcp.json: ${err?.message || err}`); } catch {} - return null; - } - const url = (data?.url ? String(data.url) : '').replace(/\/$/, ''); - const route = (data?.route ? String(data.route) : '/__client-logs') as `/${string}`; - if (url && /^(http:\/\/127\.0\.0\.1|http:\/\/localhost)/.test(url)) return { url, route }; + const p = joinPath(process.cwd(), '.browser-echo-mcp.json'); + if (existsSync(p)) { + const raw = readFileSync(p, 'utf-8'); + let data: any; + try { data = JSON.parse(raw); } + catch (err: any) { + try { server.config.logger.warn(`${options.tag} failed to parse .browser-echo-mcp.json: ${err?.message || err}`); } catch {} + return null; } - const up = dirname(dir); - if (up === dir) break; - dir = up; + const url = (data?.url ? String(data.url) : '').replace(/\/$/, ''); + const route = (data?.route ? String(data.route) : '/__client-logs') as `/${string}`; + if (url && /^(http:\/\/127\.0\.0\.1|http:\/\/localhost)/.test(url)) return { url, route }; } } catch {} return null; @@ -168,8 +177,7 @@ function attachMiddleware(server: any, options: ResolvedOptions) { resolvedIngest = ''; } - // Resolve once at startup - resolveOnce(); + // Defer resolution until needed to avoid probing during startup server.middlewares.use(options.route, (req: import('http').IncomingMessage, res: import('http').ServerResponse, next: Function) => { if (req.method !== 'POST') return next(); @@ -183,6 +191,8 @@ function attachMiddleware(server: any, options: ResolvedOptions) { // Mirror to MCP server if configured let targetIngest = resolvedIngest || ''; if (!targetIngest) { + // Only attempt discovery when configured explicitly via .browser-echo-mcp.json + // Avoid probing default dev ports implicitly try { await resolveOnce(); } catch {} targetIngest = resolvedIngest || ''; } @@ -205,10 +215,11 @@ function attachMiddleware(server: any, options: ResolvedOptions) { const sid = (payload.sessionId ?? 'anon').slice(0, 8); for (const entry of payload.entries) { const level = normalizeLevel(entry.level); + const tag = entry.tag || options.tag; const truncated = typeof entry.text === 'string' && entry.text.length > options.truncate - ? entry.text.slice(0, options.truncate) + '… (truncated)' + ? entry.text.slice(0, options.truncate) + '... (truncated)' : entry.text; - let line = `${options.tag} [${sid}] ${level.toUpperCase()}: ${truncated}`; + let line = `${tag} [${sid}] ${level.toUpperCase()}: ${truncated}`; if (options.showSource && entry.source) line += ` (${entry.source})`; const colored = options.colors ? colorize(level, line) : line; if (shouldPrint) print(logger, level, colored); @@ -229,7 +240,13 @@ function attachMiddleware(server: any, options: ResolvedOptions) { : ` ${(String(entry.stack).split(/\r?\n/g).find((l) => l.trim().length > 0) || '').trim()}`; toFile.push(stackLines); } - try { appendFileSync(logFilePath, toFile.join('\n') + '\n'); } catch {} + let outPath = defaultFilePath; + if (options.fileLog.split) { + const tagKey = String(tag || '[browser]').replace(/^[\[]|[\]]$/g, '').toLowerCase().replace(/\s+/g, '-'); + outPath = joinPath(baseLogDir, tagKey, `dev-${sessionStamp}.log`); + try { mkdirSync(dirname(outPath), { recursive: true }); } catch {} + } + try { appendFileSync(outPath, toFile.join('\n') + '\n'); } catch {} } } res.statusCode = 204; res.end(); @@ -274,48 +291,39 @@ function colorize(level: BrowserLogLevel, message: string): string { } } -type ClientPayload = { sessionId?: string; entries: Array<{ level: BrowserLogLevel | string; text: string; time?: number; stack?: string; source?: string; }>; }; +type ClientPayload = { sessionId?: string; entries: Array<{ level: BrowserLogLevel | string; text: string; time?: number; stack?: string; source?: string; tag?: string; }>; }; -function makeClientModule(options: Required) { - const include = JSON.stringify(options.include); - const preserve = JSON.stringify(options.preserveConsole); - const route = JSON.stringify(options.route); - const tag = JSON.stringify(options.tag); - const batchSize = String(options.batch?.size ?? 20); - const batchInterval = String(options.batch?.interval ?? 300); - return ` -const __INSTALLED_KEY = '__vite_browser_echo_installed__'; -if (!window[__INSTALLED_KEY]) { - window[__INSTALLED_KEY] = true; - const INCLUDE = ${include}; - const PRESERVE = ${preserve}; - const ROUTE = ${route}; - const TAG = ${tag}; - const BATCH_SIZE = ${batchSize} | 0; - const BATCH_INTERVAL = ${batchInterval} | 0; - const SESSION = (function(){try{const a=new Uint8Array(8);crypto.getRandomValues(a);return Array.from(a).map(b=>b.toString(16).padStart(2,'0')).join('')}catch{return String(Math.random()).slice(2,10)}})(); - const queue = []; let timer = null; - function enqueue(entry){ queue.push(entry); if (queue.length >= BATCH_SIZE) flush(); else if (!timer) timer = setTimeout(flush, BATCH_INTERVAL); } - function flush(){ if (timer) { clearTimeout(timer); timer = null; } if (!queue.length) return; - const payload = JSON.stringify({ sessionId: SESSION, entries: queue.splice(0, queue.length) }); - try { if (navigator.sendBeacon) { navigator.sendBeacon(ROUTE, new Blob([payload], {type:'application/json'})); } else { fetch(ROUTE, { method: 'POST', headers:{'content-type':'application/json'}, body: payload, keepalive: true, cache: 'no-store' }).catch(()=>{}); } } catch {} - } - document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') flush(); }); - addEventListener('pagehide', flush); addEventListener('beforeunload', flush); - const ORIGINAL = {}; - for (const level of INCLUDE) { - const orig = console[level] ? console[level].bind(console) : console.log.bind(console); - ORIGINAL[level] = orig; - console[level] = (...args) => { - const text = args.map((v)=>{try{if(typeof v==='string') return v; if(v instanceof Error) return (v.name||'Error')+': '+(v.message||''); const seen=new WeakSet(); return JSON.stringify(v,(k,val)=>{ if(typeof val==='bigint') return String(val)+'n'; if(typeof val==='function') return '[Function '+(val.name||'anonymous')+']'; if(val instanceof Error) return {name:val.name,message:val.message,stack:val.stack}; if(typeof val==='symbol') return val.toString(); if(val && typeof val==='object'){ if(seen.has(val)) return '[Circular]'; seen.add(val); } return val; }); } catch { try { return String(v) } catch { return '[Unserializable]' } }}).join(' '); - const stack = (new Error()).stack?.split('\\n').slice(1).filter(l=>!/virtual:browser-echo|enqueue|flush/.test(l)).join('\\n') || ''; - const srcMatch = stack.match(/\\(?((?:file:\\/\\/|https?:\\/\\/|\\/)[^) \\n]+):(\\d+):(\\d+)\\)?/); - const source = srcMatch ? (srcMatch[1]+':'+srcMatch[2]+':'+srcMatch[3]) : ''; - enqueue({ level, text, time: Date.now(), stack, source }); - if (PRESERVE) { try { orig(...args) } catch {} } - }; - } - try { ORIGINAL['info']?.(TAG + ' forwarding console logs to ' + ROUTE + ' (session ' + SESSION + ')'); } catch {} +function resolveCoreEntry(): string { + try { + const p = __require.resolve('@browser-echo/core/dist/index.mjs'); + return '/@fs/' + p.replace(/\\/g, '/'); + } catch {} + try { + const p = __require.resolve('@browser-echo/core'); + return '/@fs/' + p.replace(/\\/g, '/'); + } catch {} + return ''; } -`; + +function makeClientModule(options: Required) { + const payload = { + route: options.route, + include: options.include, + preserveConsole: options.preserveConsole, + tag: options.tag, + batch: options.batch, + stackMode: options.stackMode, + networkLogs: options.networkLogs, + }; + const coreEntry = resolveCoreEntry(); + const importLine = coreEntry + ? `import { initBrowserEcho } from '${coreEntry}';` + : `import { initBrowserEcho } from '@browser-echo/core';`; + const code = [ + importLine, + `if (typeof window !== 'undefined') {`, + ` initBrowserEcho(${JSON.stringify(payload)});`, + `}` + ].join('\n'); + return code; } diff --git a/packages/vite/test/plugin.config.smoke.test.ts b/packages/vite/test/plugin.config.smoke.test.ts index bbd66e9..0112303 100644 --- a/packages/vite/test/plugin.config.smoke.test.ts +++ b/packages/vite/test/plugin.config.smoke.test.ts @@ -37,10 +37,10 @@ describe('Vite plugin configuration (smoke)', () => { const rid = (p as any).resolveId?.('virtual:browser-echo'); const code: string = (p as any).load?.(rid); expect(typeof code).toBe('string'); - // Default route and merged batch values appear in the virtual module - expect(code).toContain('const ROUTE = "/__client-logs"'); - expect(code).toContain('const BATCH_SIZE = 99'); - expect(code).toContain('const BATCH_INTERVAL = 300'); + // Virtual module now calls initBrowserEcho with JSON payload + expect(code).toContain('initBrowserEcho('); + expect(code).toContain('"route":"/__client-logs"'); + expect(code).toContain('"batch":{"size":99,"interval":300}'); }); it('forwards to MCP and suppresses terminal when MCP URL is set', async () => { @@ -115,8 +115,9 @@ describe('Vite plugin configuration (smoke)', () => { const p = browserEcho({ include: ['error'], route: '/__client-logs' }); const rid = (p as any).resolveId?.('virtual:browser-echo'); const code: string = (p as any).load?.(rid); - expect(code).toContain('const INCLUDE = ["error"]'); - expect(code).toContain('const ROUTE = "/__client-logs"'); + expect(code).toContain('initBrowserEcho('); + expect(code).toContain('"include":["error"]'); + expect(code).toContain('"route":"/__client-logs"'); expect(code).not.toContain('BROWSER_ECHO_MCP'); }); }); diff --git a/packages/vue/README.md b/packages/vue/README.md index b3cada3..66fb549 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -184,6 +184,8 @@ export default defineConfig({ For Vue (non-Vite) apps, MCP forwarding depends on your server-side route implementation. The Vue provider only handles browser-side log collection. +Note: Network capture (fetch/XHR/WS) is available via `@browser-echo/core` and framework providers (Vite, Next, Nuxt). This Vue (non-Vite) package does not inject network capture on its own. + **📖 [First, set up the MCP server](../mcp/README.md#installation) for your AI assistant, then configure framework options below.** ### Environment Variables diff --git a/packages/vue/package.json b/packages/vue/package.json index 9d4f767..3713f27 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@browser-echo/vue", - "version": "1.0.1", + "version": "1.1.0-alpha.1", "type": "module", "sideEffects": false, "repository": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2ebdf8..86d919a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,11 +47,11 @@ importers: version: 19.1.1(react@19.1.1) devDependencies: '@browser-echo/mcp': - specifier: workspace:* - version: link:../../packages/mcp + specifier: 1.0.2 + version: 1.0.2(express@5.1.0) '@browser-echo/next': - specifier: workspace:* - version: link:../../packages/next + specifier: 1.0.2 + version: 1.0.2(next@15.4.7(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@eslint/eslintrc': specifier: ^3 version: 3.3.1 @@ -93,8 +93,8 @@ importers: version: 4.5.1(vue@3.5.18(typescript@5.9.2)) devDependencies: '@browser-echo/nuxt': - specifier: workspace:* - version: link:../../packages/nuxt + specifier: 1.0.2 + version: 1.0.2(@nuxt/kit@4.0.3(magicast@0.3.5)) example/react-vite-app: dependencies: @@ -106,8 +106,8 @@ importers: version: 19.1.1(react@19.1.1) devDependencies: '@browser-echo/vite': - specifier: workspace:* - version: link:../../packages/vite + specifier: 1.0.2 + version: 1.0.2(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.4)(yaml@2.8.1)) '@eslint/js': specifier: ^9.17.0 version: 9.33.0 @@ -164,8 +164,8 @@ importers: version: 4.0.17 devDependencies: '@browser-echo/vite': - specifier: workspace:* - version: link:../../packages/vite + specifier: 1.0.2 + version: 1.0.2(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.4)(yaml@2.8.1)) '@types/node': specifier: ^24.3.0 version: 24.3.0 @@ -204,8 +204,8 @@ importers: version: 3.5.18(typescript@5.9.2) devDependencies: '@browser-echo/vite': - specifier: workspace:* - version: link:../../packages/vite + specifier: 1.0.2 + version: 1.0.2(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.4)(yaml@2.8.1)) '@vitejs/plugin-vue': specifier: ^6.0.1 version: 6.0.1(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.4)(yaml@2.8.1))(vue@3.5.18(typescript@5.9.2)) @@ -518,6 +518,33 @@ packages: resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} engines: {node: '>=6.9.0'} + '@browser-echo/core@1.0.2': + resolution: {integrity: sha512-apc0JYYFADGEU9lJ94IimSSXp9yz3RTn4Ipz2xX5TBfozYhejv9WgFTsUmkvyVNzEUCC8RpBfz956M510HAvyw==} + + '@browser-echo/mcp@1.0.2': + resolution: {integrity: sha512-rUNS9pIrMJOxmtFWMU+ihtVngkTXyqEg0Q/tN4nz1GyZls4F1dTqqpRs2Dp5aMWzEnVBXPlNBZNhzuGBrFRDyw==} + hasBin: true + peerDependencies: + express: ^4 || ^5 + + '@browser-echo/next@1.0.2': + resolution: {integrity: sha512-gXjAzJ/FVanHfL+7JNxOXeNQAAhZDkB9nOvzxN72L4DXiFkwFlKUlUMEAwyLQyotPlHsJOpKB77eEXlLmro2rQ==} + hasBin: true + peerDependencies: + next: '>=13.4.0' + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@browser-echo/nuxt@1.0.2': + resolution: {integrity: sha512-RtQWW5RSKyDulmRhBxzaJcH8V3nCHuzdDgVB9S0OmFjup5kReB0sXAt6P3X2PzZMnyWc/fbcByrHuvFzwQVm7Q==} + peerDependencies: + '@nuxt/kit': '>=3.11.0' + + '@browser-echo/vite@1.0.2': + resolution: {integrity: sha512-EVFU6ABVk0exGyNBxmKsbPm0BUAs/mD8CTC27T1vIVW4mVI/LZu9ldzEjcFMSzteFtc+iad2OsJN8fMAnRJZZg==} + peerDependencies: + vite: '>=4.0.0' + '@cloudflare/kv-asset-handler@0.4.0': resolution: {integrity: sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==} engines: {node: '>=18.0.0'} @@ -7290,6 +7317,36 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@browser-echo/core@1.0.2': {} + + '@browser-echo/mcp@1.0.2(express@5.1.0)': + dependencies: + '@modelcontextprotocol/sdk': 1.17.2 + citty: 0.1.6 + express: 5.1.0 + h3: 1.15.4 + zod: 4.0.17 + transitivePeerDependencies: + - supports-color + + '@browser-echo/next@1.0.2(next@15.4.7(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@browser-echo/core': 1.0.2 + next: 15.4.7(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + + '@browser-echo/nuxt@1.0.2(@nuxt/kit@4.0.3(magicast@0.3.5))': + dependencies: + '@browser-echo/core': 1.0.2 + '@nuxt/kit': 4.0.3(magicast@0.3.5) + + '@browser-echo/vite@1.0.2(vite@7.1.3(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.4)(yaml@2.8.1))': + dependencies: + '@browser-echo/core': 1.0.2 + ansis: 3.17.0 + vite: 7.1.3(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.4)(yaml@2.8.1) + '@cloudflare/kv-asset-handler@0.4.0': dependencies: mime: 3.0.0