diff --git a/src/api/index.js b/src/api/index.js index 7ac42e9..613960d 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -8,6 +8,7 @@ import { loggerMiddleware } from './middleware/logger.js'; import env from '../config/env.js'; import { authMiddleware } from './middleware/auth.js'; import { ensureConnection } from '../db/store.js'; +import { closeAll as closeAllSse } from './sse.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -133,8 +134,12 @@ export async function startApi() { export async function stopApi() { if (!server) return; + // Long-lived SSE (and keep-alive) sockets otherwise block server.close() + // forever — node --watch hangs on "Waiting for graceful termination...". + closeAllSse(); await new Promise((resolve, reject) => { server.close((err) => (err ? reject(err) : resolve())); + server.closeAllConnections(); }); server = undefined; } diff --git a/src/api/sse.js b/src/api/sse.js index 1c01999..f6fdb9f 100644 --- a/src/api/sse.js +++ b/src/api/sse.js @@ -3,7 +3,7 @@ * after a successful mutation so other tabs/users can refetch. */ -/** @typedef {{ res: import('express').Response, companyId: number, userId: number }} SseClient */ +/** @typedef {{ res: import('express').Response, companyId: number, userId: number, keepAlive: NodeJS.Timeout }} SseClient */ /** @type {Set} */ const clients = new Set(); @@ -18,16 +18,19 @@ const KEEP_ALIVE_MS = 25_000; */ export function subscribe(res, { companyId, userId }) { /** @type {SseClient} */ - const client = { res, companyId, userId }; + const client = { + res, + companyId, + userId, + keepAlive: setInterval(() => { + if (res.writableEnded) return; + res.write(': keepalive\n\n'); + }, KEEP_ALIVE_MS), + }; clients.add(client); - const keepAlive = setInterval(() => { - if (res.writableEnded) return; - res.write(': keepalive\n\n'); - }, KEEP_ALIVE_MS); - const unsubscribe = () => { - clearInterval(keepAlive); + clearInterval(client.keepAlive); clients.delete(client); }; @@ -35,6 +38,20 @@ export function subscribe(res, { companyId, userId }) { return unsubscribe; } +/** + * End every open SSE stream so `server.close()` can finish (e.g. node --watch). + * EventSource clients reconnect to the new process. + */ +export function closeAll() { + for (const client of [...clients]) { + clearInterval(client.keepAlive); + clients.delete(client); + if (!client.res.writableEnded) { + client.res.end(); + } + } +} + /** * Broadcast a named signal to subscribers of the given company. * @param {string} signal - SSE event name (e.g. `category`)