11// Avoid exporting Vite types to prevent cross-version type mismatches in consumers
22import ansis from 'ansis' ;
33import type { BrowserLogLevel } from '@browser-echo/core' ;
4- import { mkdirSync , appendFileSync } from 'node:fs' ;
4+ import { mkdirSync , appendFileSync , existsSync , readFileSync } from 'node:fs' ;
55import { dirname , join as joinPath } from 'node:path' ;
6+ import { tmpdir } from 'node:os' ;
67
78export interface BrowserLogsToTerminalOptions {
89 enabled ?: boolean ;
@@ -18,12 +19,18 @@ export interface BrowserLogsToTerminalOptions {
1819 truncate ?: number ;
1920 fileLog ?: { enabled ?: boolean ; dir ?: string } ;
2021 mcp ?: { url ?: string ; routeLogs ?: `/${string } `; suppressTerminal ?: boolean ; headers ?: Record < string , string > } ;
22+ /** Enable MCP auto-discovery (env → discovery file → port scan) */
23+ discoverMcp ?: boolean ;
24+ /** Refresh interval for discovery (ms) */
25+ discoveryRefreshMs ?: number ;
26+ /** Ports to scan for /health when discovering MCP */
27+ discoveryPorts ?: number [ ] ;
2128}
2229
2330type ResolvedOptions = Required < Omit < BrowserLogsToTerminalOptions , 'batch' | 'fileLog' | 'mcp' > > & {
2431 batch : Required < NonNullable < BrowserLogsToTerminalOptions [ 'batch' ] > > ;
2532 fileLog : Required < NonNullable < BrowserLogsToTerminalOptions [ 'fileLog' ] > > ;
26- mcp : { url : string ; routeLogs : `/${string } `; suppressTerminal : boolean ; headers : Record < string , string > } ;
33+ mcp : { url : string ; routeLogs : `/${string } `; suppressTerminal : boolean ; headers : Record < string , string > ; suppressProvided : boolean } ;
2734} ;
2835
2936const DEFAULTS : ResolvedOptions = {
@@ -39,7 +46,10 @@ const DEFAULTS: ResolvedOptions = {
3946 batch : { size : 20 , interval : 300 } ,
4047 truncate : 10_000 ,
4148 fileLog : { enabled : false , dir : 'logs/frontend' } ,
42- mcp : { url : '' , routeLogs : '/__client-logs' , suppressTerminal : true , headers : { } }
49+ mcp : { url : '' , routeLogs : '/__client-logs' , suppressTerminal : true , headers : { } } ,
50+ discoverMcp : true ,
51+ discoveryRefreshMs : 30_000 ,
52+ discoveryPorts : [ 5179 , 5178 , 3001 , 4000 , 5173 ]
4353} ;
4454
4555export default function browserEcho ( opts : BrowserLogsToTerminalOptions = { } ) : any {
@@ -49,12 +59,11 @@ export default function browserEcho(opts: BrowserLogsToTerminalOptions = {}): an
4959 batch : { ...DEFAULTS . batch , ...( opts . batch ?? { } ) } ,
5060 fileLog : { ...DEFAULTS . fileLog , ...( opts . fileLog ?? { } ) } ,
5161 mcp : {
52- url : opts . mcp ?. url || process . env . BROWSER_ECHO_MCP_URL || '' ,
62+ url : normalizeMcpBaseUrl ( opts . mcp ?. url || process . env . BROWSER_ECHO_MCP_URL || '' ) ,
5363 routeLogs : ( opts . mcp ?. routeLogs || ( process . env . BROWSER_ECHO_MCP_LOGS_ROUTE as `/${string } `) || '/__client-logs' ) as `/${string } `,
54- suppressTerminal : typeof opts . mcp ?. suppressTerminal === 'boolean'
55- ? opts . mcp . suppressTerminal
56- : ! ! ( opts . mcp ?. url || process . env . BROWSER_ECHO_MCP_URL ) && process . env . BROWSER_ECHO_SUPPRESS_TERMINAL !== '0' ,
57- headers : opts . mcp ?. headers || { }
64+ suppressTerminal : typeof opts . mcp ?. suppressTerminal === 'boolean' ? opts . mcp . suppressTerminal : false ,
65+ headers : opts . mcp ?. headers || { } ,
66+ suppressProvided : typeof opts . mcp ?. suppressTerminal === 'boolean'
5867 }
5968 } ;
6069 const VIRTUAL_ID = '\0virtual:browser-echo' ;
@@ -84,12 +93,114 @@ export default function browserEcho(opts: BrowserLogsToTerminalOptions = {}): an
8493 } ;
8594}
8695
96+ function normalizeMcpBaseUrl ( input : string | undefined ) : string {
97+ if ( ! input ) return '' ;
98+ const raw = String ( input ) . trim ( ) ;
99+ if ( ! raw ) return '' ;
100+ const noSlash = raw . replace ( / \/ + $ / , '' ) ;
101+ // If a full MCP URL is provided (ending in /mcp), convert to base
102+ return noSlash . replace ( / \/ m c p $ / i, '' ) ;
103+ }
104+
87105function attachMiddleware ( server : any , options : ResolvedOptions ) {
88106 const sessionStamp = new Date ( ) . toISOString ( ) . replace ( / [: .] / g, '-' ) ;
89107 const logFilePath = joinPath ( options . fileLog . dir , `dev-${ sessionStamp } .log` ) ;
90108 if ( options . fileLog . enabled ) { try { mkdirSync ( dirname ( logFilePath ) , { recursive : true } ) ; } catch { } }
91109
92- const mcpUrl = options . mcp . url ? options . mcp . url . replace ( / \/ $ / , '' ) : '' ;
110+ // Dynamic MCP ingest resolution (explicit → env → discovery file → port scan)
111+ let resolvedBase = options . mcp . url ? options . mcp . url . replace ( / \/ $ / , '' ) : '' ;
112+ let resolvedIngest = resolvedBase ? `${ resolvedBase } ${ options . mcp . routeLogs } ` : '' ;
113+ let lastAnnouncement = '' ;
114+
115+ const refreshMs = Math . max ( 1_000 , Number ( options . discoveryRefreshMs || 30_000 ) | 0 ) ;
116+
117+ const announce = ( msg : string ) => {
118+ if ( msg && msg !== lastAnnouncement ) {
119+ try { server . config . logger . info ( msg ) ; } catch { }
120+ lastAnnouncement = msg ;
121+ }
122+ } ;
123+
124+ async function tryPingHealth ( base : string , timeoutMs = 400 ) : Promise < boolean > {
125+ try {
126+ const ctrl = new AbortController ( ) ;
127+ const t = setTimeout ( ( ) => ctrl . abort ( ) , timeoutMs ) ;
128+ const res = await fetch ( `${ base } /health` , { signal : ctrl . signal as any , cache : 'no-store' as any } ) ;
129+ clearTimeout ( t ) ;
130+ return ! ! res ?. ok ;
131+ } catch { return false ; }
132+ }
133+
134+ function readDiscoveryFile ( ) : { url : string ; routeLogs ?: string ; ts ?: number } | null {
135+ try {
136+ const candidates = [
137+ joinPath ( process . cwd ( ) , '.browser-echo-mcp.json' ) ,
138+ joinPath ( tmpdir ( ) , 'browser-echo-mcp.json' )
139+ ] ;
140+ for ( const p of candidates ) {
141+ try {
142+ if ( ! existsSync ( p ) ) continue ;
143+ const raw = readFileSync ( p , 'utf-8' ) ;
144+ const data = JSON . parse ( raw ) ;
145+ const url = ( data ?. url ? String ( data . url ) : '' ) . replace ( / \/ $ / , '' ) ;
146+ const routeLogs = data ?. routeLogs ? String ( data . routeLogs ) : undefined ;
147+ const ts = typeof data ?. timestamp === 'number' ? data . timestamp : undefined ;
148+ if ( url ) return { url, routeLogs, ts } ;
149+ } catch { }
150+ }
151+ } catch { }
152+ return null ;
153+ }
154+
155+ async function resolveMcp ( ) {
156+ if ( ! options . discoverMcp ) return ;
157+ // 1) Explicit (already normalized)
158+ const explicit = normalizeMcpBaseUrl ( options . mcp . url || process . env . BROWSER_ECHO_MCP_URL || '' ) ;
159+ if ( explicit ) {
160+ const base = explicit . replace ( / \/ $ / , '' ) ;
161+ resolvedBase = base ;
162+ resolvedIngest = `${ base } ${ options . mcp . routeLogs } ` ;
163+ announce ( `${ options . tag } forwarding logs to MCP ingest at ${ resolvedIngest } ` ) ;
164+ return ;
165+ }
166+ // 2) Discovery file (fresh within 60s)
167+ const disc = readDiscoveryFile ( ) ;
168+ if ( disc && disc . url ) {
169+ const ageOk = ! disc . ts || ( Date . now ( ) - Number ( disc . ts ) ) < 60_000 ;
170+ if ( ageOk ) {
171+ const base = String ( disc . url ) . replace ( / \/ $ / , '' ) ;
172+ const routeLogs = ( disc . routeLogs as `/${string } `) || options . mcp . routeLogs ;
173+ resolvedBase = base ;
174+ resolvedIngest = `${ base } ${ routeLogs } ` ;
175+ announce ( `${ options . tag } forwarding logs to MCP ingest at ${ resolvedIngest } ` ) ;
176+ return ;
177+ }
178+ }
179+ // 3) Port scan common ports
180+ for ( const port of options . discoveryPorts || [ ] ) {
181+ for ( const host of [ `http://127.0.0.1:${ port } ` , `http://localhost:${ port } ` ] ) {
182+ if ( await tryPingHealth ( host ) ) {
183+ resolvedBase = host ;
184+ resolvedIngest = `${ host } ${ options . mcp . routeLogs } ` ;
185+ announce ( `${ options . tag } forwarding logs to MCP ingest at ${ resolvedIngest } ` ) ;
186+ return ;
187+ }
188+ }
189+ }
190+ // 4) Not found
191+ if ( resolvedIngest ) {
192+ // lost connection → inform once
193+ announce ( `${ options . tag } no MCP detected; logging locally. To forward, set BROWSER_ECHO_MCP_URL or start MCP.` ) ;
194+ }
195+ resolvedBase = '' ;
196+ resolvedIngest = '' ;
197+ }
198+
199+ // Kick off periodic discovery
200+ if ( options . discoverMcp ) {
201+ resolveMcp ( ) ;
202+ try { const h = setInterval ( resolveMcp , refreshMs ) ; ( h as any ) . unref ?.( ) ; } catch { }
203+ }
93204
94205 server . middlewares . use ( options . route , ( req , res , next ) => {
95206 if ( req . method !== 'POST' ) return next ( ) ;
@@ -101,11 +212,11 @@ function attachMiddleware(server: any, options: ResolvedOptions) {
101212 if ( ! payload || ! Array . isArray ( payload . entries ) ) { res . statusCode = 400 ; res . end ( 'invalid payload' ) ; return ; }
102213
103214 // Mirror to MCP server if configured
104- if ( mcpUrl ) {
215+ const targetIngest = resolvedIngest || '' ;
216+ if ( targetIngest ) {
105217 try {
106- const target = `${ mcpUrl } ${ options . mcp . routeLogs } ` ;
107218 // do not await
108- fetch ( target , {
219+ fetch ( targetIngest , {
109220 method : 'POST' ,
110221 headers : { 'content-type' : 'application/json' , ...options . mcp . headers } ,
111222 body : JSON . stringify ( payload ) ,
@@ -116,7 +227,15 @@ function attachMiddleware(server: any, options: ResolvedOptions) {
116227 }
117228
118229 const logger = server . config . logger ;
119- const shouldPrint = ! ( options . mcp . suppressTerminal && mcpUrl ) ;
230+ const envVal = process . env . BROWSER_ECHO_SUPPRESS_TERMINAL ;
231+ const forceSuppress = envVal === '1' ;
232+ const forcePrint = envVal === '0' ;
233+ let suppressTerminal : boolean ;
234+ if ( forceSuppress ) suppressTerminal = true ;
235+ else if ( forcePrint ) suppressTerminal = false ;
236+ else if ( options . mcp . suppressProvided ) suppressTerminal = options . mcp . suppressTerminal && ! ! targetIngest ;
237+ else suppressTerminal = ! ! targetIngest ; // auto: suppress when forwarding active
238+ const shouldPrint = ! suppressTerminal ;
120239 const sid = ( payload . sessionId ?? 'anon' ) . slice ( 0 , 8 ) ;
121240 for ( const entry of payload . entries ) {
122241 const level = normalizeLevel ( entry . level ) ;
0 commit comments