Keep notification SSE stream alive behind proxies (#1516)

* fix: keep notification SSE stream alive

* Fix PR comments

* fix: cover notification stream error cleanup

---------

Co-authored-by: Konstantin Zolin <zolin_ka@vk.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
kostazol
2026-06-11 19:48:06 +03:00
committed by GitHub
co-authored by Konstantin Zolin Bohdan Triapitsyn
parent f26950fa4e
commit 284d72bef2
3 changed files with 101 additions and 30 deletions
@@ -26,6 +26,7 @@ This module provides notification message preparation utilities for the web serv
- `DELETE /api/push/subscribe`
- `POST /api/push/visibility`
- `GET /api/push/visibility`
- `GET /api/notifications/stream`
- `GET /api/session-activity`
- `GET /api/sessions/snapshot`
- `GET /api/sessions/status`
@@ -86,6 +87,7 @@ This module provides notification message preparation utilities for the web serv
### Default values
- `DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH`: 250 (default max length for notification text).
- `NOTIFICATION_SSE_HEARTBEAT_INTERVAL_MS`: 20000 (notification SSE comment heartbeat interval).
## Settings object format
@@ -22,6 +22,8 @@ const parsePushUnsubscribeBody = (body) => {
return { endpoint: endpoint.trim() };
};
export const NOTIFICATION_SSE_HEARTBEAT_INTERVAL_MS = 20_000;
export const registerNotificationRoutes = (app, dependencies) => {
const {
uiAuthController,
@@ -180,17 +182,50 @@ export const registerNotificationRoutes = (app, dependencies) => {
const clients = getUiNotificationClients();
clients.add(res);
let closed = false;
let heartbeatTimer = null;
const cleanup = () => {
if (closed) {
return;
}
closed = true;
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
clients.delete(res);
};
req.on('close', cleanup);
res.on('error', cleanup);
const flushSse = () => {
res.flush?.();
};
heartbeatTimer = setInterval(() => {
if (closed || res.writableEnded || res.destroyed) {
cleanup();
return;
}
try {
res.write(':heartbeat\n\n');
flushSse();
} catch {
cleanup();
}
}, NOTIFICATION_SSE_HEARTBEAT_INTERVAL_MS);
try {
writeSseEvent(res, {
type: 'openchamber:notification-stream-ready',
properties: { uiToken },
});
flushSse();
} catch {
cleanup();
}
req.on('close', () => {
clients.delete(res);
});
});
app.get('/api/session-activity', (_req, res) => {