@@ -7,6 +7,7 @@ interface SSEClient {
77 res : Response ;
88 subscriptions : Set < string > ;
99 ip : string ;
10+ lastActivityAt : number ;
1011}
1112
1213const MAX_CONNECTIONS_PER_IP = 5 ;
@@ -24,6 +25,37 @@ class SSEService {
2425 private readonly ipConnectionCounts : Map < string , number > = new Map ( ) ;
2526 private shuttingDown = false ;
2627 private perIpPeakConnections = 0 ;
28+ private heartbeatInterval ?: NodeJS . Timeout ;
29+
30+ constructor ( ) {
31+ this . startHeartbeat ( ) ;
32+ }
33+
34+ private startHeartbeat ( ) : void {
35+ this . heartbeatInterval = setInterval ( ( ) => {
36+ const now = Date . now ( ) ;
37+ const timeoutMs = 5 * 60 * 1000 ;
38+
39+ for ( const [ clientId , client ] of this . clients . entries ( ) ) {
40+ if ( now - client . lastActivityAt > timeoutMs ) {
41+ logger . info ( `[SSEService] Connection timed out: ${ clientId } , ip: ${ client . ip } ` ) ;
42+ try {
43+ client . res . end ( ) ;
44+ } catch ( err ) {
45+ // ignore
46+ }
47+ continue ;
48+ }
49+
50+ try {
51+ client . res . write ( ': keep-alive\n\n' ) ;
52+ logger . debug ( `[SSEService] Heartbeat sent: ${ clientId } ` ) ;
53+ } catch ( err ) {
54+ // ignore
55+ }
56+ }
57+ } , 30 * 1000 ) ;
58+ }
2759
2860 private readonly maxConnections : number = ( ( ) => {
2961 const parsed = Number . parseInt ( process . env . MAX_SSE_CONNECTIONS ?? '10000' , 10 ) ;
@@ -88,11 +120,12 @@ class SSEService {
88120 res,
89121 subscriptions : new Set ( subscriptions ) ,
90122 ip,
123+ lastActivityAt : Date . now ( ) ,
91124 } ;
92125
93126 this . clients . set ( clientId , client ) ;
94127 logger . info (
95- `SSE client connected : ${ clientId } , ip: ${ ip } , subscriptions: ${ subscriptions . join ( ', ' ) } `
128+ `[SSEService] Connection opened : ${ clientId } , ip: ${ ip } , subscriptions: ${ subscriptions . join ( ', ' ) } `
96129 ) ;
97130
98131 res . on ( 'close' , ( ) => {
@@ -113,15 +146,19 @@ class SSEService {
113146 this . ipConnectionCounts . set ( client . ip , currentIpCount - 1 ) ;
114147 }
115148
116- logger . info ( `SSE client disconnected : ${ clientId } , ip: ${ client . ip } ` ) ;
149+ logger . info ( `[SSEService] Connection closed : ${ clientId } , ip: ${ client . ip } ` ) ;
117150 }
118151
119152 sendReconnectToAll ( ) : void {
120153 this . shuttingDown = true ;
154+ if ( this . heartbeatInterval ) {
155+ clearInterval ( this . heartbeatInterval ) ;
156+ }
121157 const message = 'event: reconnect\ndata: {}\n\n' ;
122158 for ( const client of this . clients . values ( ) ) {
123159 try {
124160 client . res . write ( message ) ;
161+ client . lastActivityAt = Date . now ( ) ;
125162 } catch {
126163 // ignore write errors during shutdown
127164 }
@@ -133,7 +170,12 @@ class SSEService {
133170 const message = `event: ${ event } \ndata: ${ JSON . stringify ( data ) } \n\n` ;
134171 for ( const client of this . clients . values ( ) ) {
135172 if ( ! filter || filter ( client ) ) {
136- client . res . write ( message ) ;
173+ try {
174+ client . res . write ( message ) ;
175+ client . lastActivityAt = Date . now ( ) ;
176+ } catch ( err ) {
177+ // ignore write errors
178+ }
137179 }
138180 }
139181 }
0 commit comments