u
This commit is contained in:
269
src/automats/sseAutomat.js
Normal file
269
src/automats/sseAutomat.js
Normal file
@@ -0,0 +1,269 @@
|
||||
import { Automat } from 'react-automat';
|
||||
import authAutomat from './authAutomat';
|
||||
import artikelAutomat from './artikelAutomat';
|
||||
import { articleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat';
|
||||
import { articleIndexAutomat } from './articleIndexAutomat';
|
||||
import categoryAutomat, { invalidateAllCategoryViews } from './categoryAutomat';
|
||||
import warehouseAutomat from './warehouseAutomat';
|
||||
import attributeAutomat from './attributeAutomat';
|
||||
import propertyAutomat from './propertyAutomat';
|
||||
import supplierAutomat from './supplierAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} SseState
|
||||
* @property {'disconnected'|'connecting'|'connected'|'error'} status Connection state.
|
||||
* @property {string|null} companyId Company the stream is bound to.
|
||||
*/
|
||||
|
||||
/** @type {SseState} */
|
||||
const initialState = {
|
||||
status: 'disconnected',
|
||||
companyId: null,
|
||||
};
|
||||
|
||||
/** Event name prefix for per-article signals. */
|
||||
const ARTICLE_EVENT_PREFIX = 'article-';
|
||||
|
||||
let sseAutomat;
|
||||
/** @type {EventSource | null} */
|
||||
let eventSource = null;
|
||||
|
||||
/**
|
||||
* Handlers for statically named events (`category`, `articles`, …).
|
||||
* @type {Map<string, Set<(event: MessageEvent) => void>>}
|
||||
*/
|
||||
const handlers = new Map();
|
||||
|
||||
/**
|
||||
* Handlers per watched SKU, so one `article-<sku>` listener is attached while
|
||||
* at least one view is open on that SKU.
|
||||
* @type {Map<string, Set<(sku: string) => void>>}
|
||||
*/
|
||||
const articleWatchers = new Map();
|
||||
|
||||
function disconnect() {
|
||||
if (!eventSource) return;
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
sseAutomat.setState({ status: 'disconnected' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches every registered handler to a freshly opened stream.
|
||||
*
|
||||
* @param {EventSource} es Open event stream.
|
||||
* @returns {void}
|
||||
*/
|
||||
function attachHandlers(es) {
|
||||
for (const [event, set] of handlers) {
|
||||
for (const handler of set) {
|
||||
es.addEventListener(event, handler);
|
||||
}
|
||||
}
|
||||
for (const [sku] of articleWatchers) {
|
||||
attachArticleListener(sku);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the single `article-<sku>` listener for a watched SKU.
|
||||
*
|
||||
* The listener resolves the handler set at call time, so a SKU that lost all
|
||||
* watchers turns into a cheap no-op instead of needing removal. Listeners are
|
||||
* reclaimed whenever the stream is rebuilt on a company switch.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {void}
|
||||
*/
|
||||
function attachArticleListener(sku) {
|
||||
eventSource?.addEventListener(`${ARTICLE_EVENT_PREFIX}${sku}`, (event) => {
|
||||
const set = articleWatchers.get(sku);
|
||||
if (!set || set.size === 0) return;
|
||||
const changed = String(event.type).slice(ARTICLE_EVENT_PREFIX.length);
|
||||
for (const handler of set) handler(changed);
|
||||
});
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||
if (companyId == null) {
|
||||
disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL('/api/events', window.location.origin).href;
|
||||
sseAutomat.setState({ status: 'connecting', companyId: String(companyId) });
|
||||
|
||||
const es = new EventSource(url, { withCredentials: true });
|
||||
|
||||
// Category tree changed: refresh the tree and every open category view.
|
||||
es.addEventListener('category', () => {
|
||||
void categoryAutomat.actions.loadTree().catch((err) => {
|
||||
console.error('[sse] category tree refresh failed', err);
|
||||
});
|
||||
invalidateAllCategoryViews();
|
||||
});
|
||||
|
||||
// Article index changed: refresh the index and open category views.
|
||||
es.addEventListener('articles', () => {
|
||||
invalidateAllCategoryViews();
|
||||
void articleIndexAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] article index refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
es.addEventListener('warehouse', () => {
|
||||
void warehouseAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] warehouse refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
es.addEventListener('attributename', () => {
|
||||
void attributeAutomat.actions.load().catch((err) => {
|
||||
console.error('[sse] attributename refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
es.addEventListener('propertyname', () => {
|
||||
void propertyAutomat.actions.load().catch((err) => {
|
||||
console.error('[sse] propertyname refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
es.addEventListener('supplier', () => {
|
||||
void supplierAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] supplier refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
es.addEventListener('open', () => {
|
||||
sseAutomat.setState({ status: 'connected' });
|
||||
});
|
||||
|
||||
// EventSource reconnects on its own; only surface the state.
|
||||
es.onerror = () => {
|
||||
console.error('[sse] event stream error');
|
||||
sseAutomat.setState({ status: 'error' });
|
||||
};
|
||||
|
||||
eventSource = es;
|
||||
attachHandlers(es);
|
||||
}
|
||||
|
||||
const actions = {
|
||||
/**
|
||||
* Closes and reopens the event stream. Required after a company switch,
|
||||
* because the stream is scoped to the selected company at connect time.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
reconnect() {
|
||||
disconnect();
|
||||
connect();
|
||||
},
|
||||
|
||||
/**
|
||||
* Registers a handler for a statically named backend event.
|
||||
*
|
||||
* Registration order does not matter: handlers added while disconnected are
|
||||
* attached on the next connect.
|
||||
*
|
||||
* @param {string} event Event name (e.g. `category`, `articles`).
|
||||
* @param {(event: MessageEvent) => void} handler Signal handler.
|
||||
* @returns {() => void} Unsubscribe callback.
|
||||
*/
|
||||
on(event, handler) {
|
||||
let set = handlers.get(event);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
handlers.set(event, set);
|
||||
}
|
||||
set.add(handler);
|
||||
eventSource?.addEventListener(event, handler);
|
||||
return () => {
|
||||
eventSource?.removeEventListener(event, handler);
|
||||
set.delete(handler);
|
||||
if (set.size === 0) handlers.delete(event);
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Subscribes to `article-<sku>` signals for one SKU.
|
||||
*
|
||||
* The backend emits this event whenever that article changes. Exactly one
|
||||
* listener is attached per SKU while at least one caller is subscribed.
|
||||
*
|
||||
* @param {string} sku Article SKU to watch.
|
||||
* @param {(sku: string) => void} handler Called with the changed SKU.
|
||||
* @returns {() => void} Unsubscribe callback.
|
||||
*/
|
||||
watchArticle(sku, handler) {
|
||||
const key = String(sku);
|
||||
let set = articleWatchers.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
articleWatchers.set(key, set);
|
||||
attachArticleListener(key);
|
||||
}
|
||||
set.add(handler);
|
||||
|
||||
return () => {
|
||||
const current = articleWatchers.get(key);
|
||||
if (!current) return;
|
||||
current.delete(handler);
|
||||
if (current.size === 0) articleWatchers.delete(key);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Global `GET /api/events` connection.
|
||||
*
|
||||
* Owns the single `EventSource` for the tab and dispatches signals into the
|
||||
* domain automats. Reconnects whenever the selected company changes.
|
||||
*
|
||||
* @type {Automat<SseState>}
|
||||
*/
|
||||
sseAutomat = new Automat(initialState, actions, { name: 'sse' });
|
||||
|
||||
/**
|
||||
* Refreshes the cached article plus any open history/supplier view.
|
||||
*
|
||||
* Background refreshes stay silent: an article that is not on screen has no
|
||||
* visible subscriber to notify, so failures are swallowed.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function invalidateArticle(sku) {
|
||||
const key = String(sku);
|
||||
void artikelAutomat.actions.reloadArticle(key).catch(() => {});
|
||||
if (articleHistoryAutomat.has(key)) {
|
||||
void articleHistoryAutomat.reload(key).catch(() => {});
|
||||
}
|
||||
if (articleSupplierAutomat.has(key)) {
|
||||
void articleSupplierAutomat.reload(key).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnect when the selected company changes; disconnect on logout.
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId =
|
||||
auth.status === 'authed' && auth.user?.selectedCompanyId != null
|
||||
? String(auth.user.selectedCompanyId)
|
||||
: null;
|
||||
if (companyId === sseAutomat.state.companyId) return;
|
||||
|
||||
if (companyId == null) {
|
||||
disconnect();
|
||||
sseAutomat.setState({ companyId: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// A different company means a different stream scope.
|
||||
disconnect();
|
||||
sseAutomat.setState({ companyId });
|
||||
connect();
|
||||
});
|
||||
|
||||
export default sseAutomat;
|
||||
Reference in New Issue
Block a user