diff --git a/src/api/index.js b/src/api/index.js index f60342f..d87d2a3 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -60,8 +60,11 @@ function corsMiddleware(req, res, next) { async function createApi() { const app = express(); - app.use((_req, res, next) => { - res.setHeader('Cache-Control', 'no-store'); + app.use((req, res, next) => { + // Do not attach no-store to SSE endpoint + if (req.path !== '/api/events') { + res.setHeader('Cache-Control', 'no-store'); + } next(); }); app.use(corsMiddleware); diff --git a/src/api/routes/events.js b/src/api/routes/events.js index 607b685..8f0d1dc 100644 --- a/src/api/routes/events.js +++ b/src/api/routes/events.js @@ -27,7 +27,7 @@ export function register(app, basePath = '') { * es.addEventListener('article-42000045', () => { refetchArticle('42000045'); }); * ``` * - * Keep-alive comments (`: keepalive`) are sent roughly every 25s. + * Keep-alive comments (`: heartbeat`) are sent roughly every 20s. * Reconnect after changing the selected company (the stream is bound * to the company at connect time). * @@ -76,24 +76,28 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ - app.get(`${basePath}/events`, (req, res) => { - const companyId = req.user.selectedCompanyId; - if (companyId == null) { - return res.status(400).json({ error: 'No company selected' }); - } - - req.socket.setTimeout(0); - res.setTimeout(0); - - res.status(200); - res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); - res.setHeader('Cache-Control', 'no-cache, no-transform'); - res.setHeader('Connection', 'keep-alive'); - res.setHeader('X-Accel-Buffering', 'no'); - res.flushHeaders?.(); - - res.write(': connected\n\n'); - - subscribe(res, { companyId, userId: req.user.id }); - }); + app.get(`${basePath}/events`, handleSse); +} + +export function handleSse(req, res) { + const companyId = req.user?.selectedCompanyId; + if (companyId == null) { + return res.status(400).json({ error: 'No company selected' }); + } + + req.socket?.setTimeout(0); + res.setTimeout?.(0); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + 'Connection': 'keep-alive', + 'X-Accel-Buffering': 'no' // Instructs NGINX explicitly to disable buffering + }); + res.flushHeaders(); // Ensure headers are sent over HTTP/2 frame immediately + + // Send an initial ping to establish frame + res.write(': ping\n\n'); + + subscribe(res, { companyId, userId: req.user?.id }, req); } diff --git a/src/api/sse.js b/src/api/sse.js index f6fdb9f..a897ef0 100644 --- a/src/api/sse.js +++ b/src/api/sse.js @@ -8,15 +8,16 @@ /** @type {Set} */ const clients = new Set(); -const KEEP_ALIVE_MS = 25_000; +const KEEP_ALIVE_MS = 20_000; /** * Register an open SSE response. Caller must write the initial headers. * @param {import('express').Response} res * @param {{ companyId: number, userId: number }} meta + * @param {import('express').Request} [req] * @returns {() => void} unsubscribe */ -export function subscribe(res, { companyId, userId }) { +export function subscribe(res, { companyId, userId }, req) { /** @type {SseClient} */ const client = { res, @@ -24,7 +25,7 @@ export function subscribe(res, { companyId, userId }) { userId, keepAlive: setInterval(() => { if (res.writableEnded) return; - res.write(': keepalive\n\n'); + res.write(': heartbeat\n\n'); }, KEEP_ALIVE_MS), }; clients.add(client); @@ -35,6 +36,7 @@ export function subscribe(res, { companyId, userId }) { }; res.on('close', unsubscribe); + req?.on('close', unsubscribe); return unsubscribe; }