import { subscribe } from '../sse.js'; /** * Registers the SSE update-events stream (requires auth). * @param {import('express').Application} app - Express app * @param {string} [basePath=''] - Optional URL prefix for all routes in this module */ export function register(app, basePath = '') { /** * @openapi * /events: * get: * tags: [Events] * summary: Subscribe to update signals (SSE) * description: | * Opens a Server-Sent Events stream for the authenticated user's * **selected company**. Clients receive named signals when another * request successfully mutates shared data for that company * (including the same browser's other tabs). * * Use `EventSource` with credentials so the session cookie is sent: * * ```js * const es = new EventSource('https://…/api/events', { withCredentials: true }); * es.addEventListener('category', () => { refetchCategoryTree(); }); * es.addEventListener('articles', () => { refetchArticleIndex(); }); * es.addEventListener('article-42000045', () => { refetchArticle('42000045'); }); * ``` * * 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). * * ## Signals * * Every SSE event uses the event name as the signal id; payloads are * JSON and most events send an empty object `{}`. Only `brand` emits a * richer payload. * * | Signal | Payload | Emitted after successful | * |--------|---------|--------------------------| * | `category` | `{}` | `POST /category`, `PATCH /category/{id}`, `PUT /category/order`, `DELETE /category/{id}`, `POST /category/{id}/image`, `DELETE /category/{id}/image`, `PUT /category/{id}/property` | * | `articles` | `{}` | `POST /article`, `PATCH /article/{sku}`, `DELETE /article/{sku}`, `PUT /article/{sku}/articles`-style list-level changes, `POST /category/{id}/article` and related article-assigning category mutations | * | `article-` | `{}` | `PUT /article/{sku}/weight`, `PUT /article/{sku}/prices`, `PUT /article/{sku}/eans`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/brand`, `PUT /article/{sku}/images/order`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, supplier/article-property mutations affecting the article | * | `stock` | `{}` | `POST /stock/adjust` | * | `ledger` | `{}` | `POST /stock/adjust` | * | `attributename` | `{}` | `POST /attributename`, `PUT /attributename/order`, `PATCH /attributename/{id}`, `DELETE /attributename/{id}` | * | `propertyname` | `{}` | `POST /propertyname`, `PUT /propertyname/order`, `PATCH /propertyname/{id}`, `DELETE /propertyname/{id}` | * | `labeltemplate` | `{}` | `POST /labeltemplate`, `PUT /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `DELETE /labeltemplate/{id}` | * | `printer` | `{}` | `POST /printer`, `PUT /printer/{id}`, `PATCH /printer/{id}`, `DELETE /printer/{id}` | * | `warehouse` | `{}` | `POST /warehouse`, `PUT /warehouse/{id}`, `PATCH /warehouse/{id}`, `DELETE /warehouse/{id}` | * | `vfs` | `{}` | VFS uploads and filesystem mutations such as `POST /vfs/upload`, `POST /vfs/mkdir`, `POST /vfs/rename`, `PUT /vfs/{*path}`, `DELETE /vfs/{*path}` | * | `modelconversation` | `{}` | `POST /modelconversation`, `DELETE /modelconversation/{id}` | * | `modelconversation-` | `{}` | Conversation-specific updates on the same model conversation thread; the concrete ID is used in the event name | * | `brand` | `{ sender, brandId }` | `POST /brand`, `PATCH /brand/{id}`, `DELETE /brand/{id}`, `POST /brand/{id}/image`, `DELETE /brand/{id}/image` | * * The `brand` payload is `{ sender, brandId }`: `sender` echoes the * caller's `x-client-id` header (empty string if absent) so the sender * can ignore its own event; `brandId` is the affected brand id (`null` * only if unknown). All other signals send `{}`. * * Signals are only sent when the mutation actually succeeds (2xx). * Failed or no-op validation errors do not emit. The `article-` * and `modelconversation-` event names use the concrete item id at * runtime (for example `article-42000045` and `modelconversation-123`). * security: * - cookieAuth: [] * x-markdown: events.md * x-sse-events: * - name: category * payload: "{}" * - name: articles * payload: "{}" * - name: article- * payload: "{}" * - name: stock * payload: "{}" * - name: ledger * payload: "{}" * - name: attributename * payload: "{}" * - name: propertyname * payload: "{}" * - name: labeltemplate * payload: "{}" * - name: printer * payload: "{}" * - name: warehouse * payload: "{}" * - name: vfs * payload: "{}" * - name: modelconversation * payload: "{}" * - name: modelconversation- * payload: "{}" * - name: brand * payload: "{ sender, brandId }" * responses: * 200: * description: | * SSE stream (`text/event-stream`). Each signal is an SSE event * whose name is the signal id. Most signals send `{}` as data; * `brand` sends `{ "sender": "", "brandId": 12 }`. * content: * text/event-stream: * schema: * type: string * example: | * event: category * data: {} * * event: articles * data: {} * * event: article-42000045 * data: {} * * event: brand * data: {"sender":"tab-1","brandId":12} * 400: * description: No company selected * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Unauthorized * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ // #region GET /events app.get(`${basePath}/events`, handleSse); // #endregion } 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'); const clientId = req.query?.cid || req.headers?.['x-client-id'] || null; subscribe(res, { companyId, userId: req.user?.id, clientId }, req); }