This commit is contained in:
seb
2026-09-23 15:15:45 +02:00
parent a46e9742f4
commit 68aadd483b
3 changed files with 35 additions and 26 deletions

View File

@@ -60,8 +60,11 @@ function corsMiddleware(req, res, next) {
async function createApi() { async function createApi() {
const app = express(); const app = express();
app.use((_req, res, next) => { app.use((req, res, next) => {
res.setHeader('Cache-Control', 'no-store'); // Do not attach no-store to SSE endpoint
if (req.path !== '/api/events') {
res.setHeader('Cache-Control', 'no-store');
}
next(); next();
}); });
app.use(corsMiddleware); app.use(corsMiddleware);

View File

@@ -27,7 +27,7 @@ export function register(app, basePath = '') {
* es.addEventListener('article-42000045', () => { refetchArticle('42000045'); }); * 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 * Reconnect after changing the selected company (the stream is bound
* to the company at connect time). * to the company at connect time).
* *
@@ -76,24 +76,28 @@ export function register(app, basePath = '') {
* schema: * schema:
* $ref: '#/components/schemas/Error' * $ref: '#/components/schemas/Error'
*/ */
app.get(`${basePath}/events`, (req, res) => { app.get(`${basePath}/events`, handleSse);
const companyId = req.user.selectedCompanyId; }
if (companyId == null) {
return res.status(400).json({ error: 'No company selected' }); export function handleSse(req, res) {
} const companyId = req.user?.selectedCompanyId;
if (companyId == null) {
req.socket.setTimeout(0); return res.status(400).json({ error: 'No company selected' });
res.setTimeout(0); }
res.status(200); req.socket?.setTimeout(0);
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); res.setTimeout?.(0);
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive'); res.writeHead(200, {
res.setHeader('X-Accel-Buffering', 'no'); 'Content-Type': 'text/event-stream',
res.flushHeaders?.(); 'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
res.write(': connected\n\n'); 'X-Accel-Buffering': 'no' // Instructs NGINX explicitly to disable buffering
});
subscribe(res, { companyId, userId: req.user.id }); 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);
} }

View File

@@ -8,15 +8,16 @@
/** @type {Set<SseClient>} */ /** @type {Set<SseClient>} */
const clients = new 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. * Register an open SSE response. Caller must write the initial headers.
* @param {import('express').Response} res * @param {import('express').Response} res
* @param {{ companyId: number, userId: number }} meta * @param {{ companyId: number, userId: number }} meta
* @param {import('express').Request} [req]
* @returns {() => void} unsubscribe * @returns {() => void} unsubscribe
*/ */
export function subscribe(res, { companyId, userId }) { export function subscribe(res, { companyId, userId }, req) {
/** @type {SseClient} */ /** @type {SseClient} */
const client = { const client = {
res, res,
@@ -24,7 +25,7 @@ export function subscribe(res, { companyId, userId }) {
userId, userId,
keepAlive: setInterval(() => { keepAlive: setInterval(() => {
if (res.writableEnded) return; if (res.writableEnded) return;
res.write(': keepalive\n\n'); res.write(': heartbeat\n\n');
}, KEEP_ALIVE_MS), }, KEEP_ALIVE_MS),
}; };
clients.add(client); clients.add(client);
@@ -35,6 +36,7 @@ export function subscribe(res, { companyId, userId }) {
}; };
res.on('close', unsubscribe); res.on('close', unsubscribe);
req?.on('close', unsubscribe);
return unsubscribe; return unsubscribe;
} }