@@ -61,10 +61,10 @@ export async function startServer(
6161
6262 // Always bring up an ingest-only HTTP endpoint so clients/frameworks can POST logs
6363 const host = options . host || '127.0.0.1' ;
64- // Use env override if provided, otherwise let OS pick an ephemeral port
64+ // Prefer stable 5179 for discovery; allow override via options/env; fallback handled in startIngestOnlyServer
6565 const envIngest = process . env . BROWSER_ECHO_INGEST_PORT ;
66- const requested = Number ( envIngest || 0 ) | 0 ;
67- const port = requested > 0 ? requested : 0 ;
66+ const preferred = ( options . port ?? ( envIngest ? Number ( envIngest ) | 0 : 5179 ) ) | 0 ;
67+ const port = preferred > 0 ? preferred : 5179 ;
6868 const logsRoute = options . logsRoute || '/__client-logs' ;
6969 await startIngestOnlyServer ( store , { host, port, logsRoute } ) ;
7070
@@ -251,34 +251,80 @@ export async function startIngestOnlyServer(
251251
252252 app . use ( router ) ;
253253
254- const nodeServer = createNodeServer ( toNodeListener ( app ) ) ;
254+ function configureNodeServer ( srv : any ) {
255+ try {
256+ srv . requestTimeout = 0 ;
257+ srv . headersTimeout = 0 ;
258+ typeof srv . setTimeout === 'function' && srv . setTimeout ( 0 ) ;
259+ srv . on ( 'connection' , ( socket : any ) => {
260+ try {
261+ socket . setKeepAlive ?.( true , 60_000 ) ;
262+ socket . setNoDelay ?.( true ) ;
263+ } catch { }
264+ } ) ;
265+ } catch { }
266+ }
255267
256- try {
257- nodeServer . requestTimeout = 0 ;
258- nodeServer . headersTimeout = 0 ;
259- typeof nodeServer . setTimeout === 'function' && nodeServer . setTimeout ( 0 ) ;
260- nodeServer . on ( 'connection' , ( socket : any ) => {
268+ function listenWithResult ( srv : any , host : string , port : number ) : Promise < number > {
269+ return new Promise < number > ( ( resolve , reject ) => {
270+ const onListening = ( ) => {
271+ try {
272+ const addr = srv . address ( ) ;
273+ const actual = addr && typeof addr === 'object' && 'port' in addr ? ( addr . port as number ) : port ;
274+ cleanup ( ) ;
275+ resolve ( actual ) ;
276+ } catch ( e ) {
277+ cleanup ( ) ;
278+ reject ( e ) ;
279+ }
280+ } ;
281+ const onError = ( err : any ) => { cleanup ( ) ; reject ( err ) ; } ;
282+ const cleanup = ( ) => {
283+ try { srv . off ?.( 'listening' , onListening ) ; } catch { }
284+ try { srv . off ?.( 'error' , onError ) ; } catch { }
285+ try { srv . removeListener ?.( 'listening' , onListening ) ; } catch { }
286+ try { srv . removeListener ?.( 'error' , onError ) ; } catch { }
287+ } ;
261288 try {
262- socket . setKeepAlive ?.( true , 60_000 ) ;
263- socket . setNoDelay ?.( true ) ;
264- } catch { }
289+ srv . once ( 'listening' , onListening ) ;
290+ srv . once ( 'error' , onError ) ;
291+ srv . listen ( port , host ) ;
292+ } catch ( e ) {
293+ cleanup ( ) ;
294+ reject ( e ) ;
295+ }
265296 } ) ;
266- } catch { }
297+ }
267298
268- await new Promise < void > ( ( resolve ) => nodeServer . listen ( opts . port , opts . host , ( ) => resolve ( ) ) ) ;
299+ // Prefer requested port (usually 5179); fall back to ephemeral if it's taken
300+ let nodeServer = createNodeServer ( toNodeListener ( app ) ) ;
301+ configureNodeServer ( nodeServer ) ;
269302
270- // Determine the actual port assigned (when listening on port 0)
271- const addr = nodeServer . address ( ) as any ;
272- const actualPort = ( addr && typeof addr === 'object' && 'port' in addr ) ? ( addr . port as number ) : opts . port ;
303+ let actualPort : number ;
304+ try {
305+ actualPort = await listenWithResult ( nodeServer , opts . host , opts . port ) ;
306+ } catch ( err : any ) {
307+ const isAddrInUse = err && ( err . code === 'EADDRINUSE' || String ( err . message || '' ) . includes ( 'EADDRINUSE' ) ) ;
308+ if ( isAddrInUse && opts . port !== 0 ) {
309+ try { nodeServer . close ?.( ) ; } catch { }
310+ nodeServer = createNodeServer ( toNodeListener ( app ) ) ;
311+ configureNodeServer ( nodeServer ) ;
312+ actualPort = await listenWithResult ( nodeServer , opts . host , 0 ) ;
313+ } else {
314+ throw err ;
315+ }
316+ }
273317
274- // Advertise discovery for tooling that wants to auto-detect the ingest endpoint
275- await advertiseDiscovery ( opts . host , actualPort , opts . logsRoute , { projectRoot : process . cwd ( ) , scope : 'stdio' } ) ;
318+ // Only the primary (5179) should advertise to OS tmp to avoid discovery flapping
319+ const requestedPort = opts . port ;
320+ const isAggregator = requestedPort !== 0 && actualPort === requestedPort ;
321+ await advertiseDiscovery ( opts . host , actualPort , opts . logsRoute , { projectRoot : process . cwd ( ) , scope : 'stdio' , aggregator : isAggregator } ) ;
276322
277323 // eslint-disable-next-line no-console
278324 console . error ( `Log ingest endpoint → http://${ opts . host } :${ actualPort } ${ opts . logsRoute } ` ) ;
279325}
280326
281- async function advertiseDiscovery ( host : string , port : number , logsRoute : `/${string } `, meta ?: { projectRoot ?: string ; token ?: string ; scope ?: 'http' | 'stdio' } ) {
327+ async function advertiseDiscovery ( host : string , port : number , logsRoute : `/${string } `, meta ?: { projectRoot ?: string ; token ?: string ; scope ?: 'http' | 'stdio' ; aggregator ?: boolean } ) {
282328 try {
283329 const { writeFileSync } = await import ( 'node:fs' ) ;
284330 const { join } = await import ( 'node:path' ) ;
@@ -296,7 +342,9 @@ async function advertiseDiscovery(host: string, port: number, logsRoute: `/${str
296342
297343 const files = meta ?. scope === 'http'
298344 ? [ join ( tmpdir ( ) , 'browser-echo-mcp.json' ) ]
299- : [ join ( process . cwd ( ) , '.browser-echo-mcp.json' ) , join ( tmpdir ( ) , 'browser-echo-mcp.json' ) ] ;
345+ : meta ?. aggregator
346+ ? [ join ( process . cwd ( ) , '.browser-echo-mcp.json' ) , join ( tmpdir ( ) , 'browser-echo-mcp.json' ) ]
347+ : [ join ( process . cwd ( ) , '.browser-echo-mcp.json' ) ] ;
300348
301349 for ( const f of files ) {
302350 try { writeFileSync ( f , payload ) ; } catch { }
0 commit comments