163 lines
7.6 KiB
JavaScript
163 lines
7.6 KiB
JavaScript
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-<sku>` | `{}` | `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-<id>` | `{}` | 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-<sku>`
|
|
* and `modelconversation-<id>` 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-<sku>
|
|
* 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-<id>
|
|
* 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": "<x-client-id>", "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);
|
|
}
|