Real-time streams (SSE)
Three mechanisms deliver "something changed" without a page reload. They are unrelated to each other and to domain events.
1. Server-sent events
Why a polyfill
Native EventSource cannot set request headers, and every Payability backend requires
X-Session-Token. SseService therefore uses EventSourcePolyfill from
ng-event-source:
@Injectable()
export class SseService {
private token: string;
constructor(private zone: NgZone, private store: Store<AppState>) {
this.store.select('auth').subscribe((authState) => {
if (authState && authState.user) { this.token = authState.user.jwtToken; }
});
}
getStream(sseEndpointUrl: string): Observable<any> {
return new Observable<any>((observer) => {
const eventSource = new EventSourcePolyfill(sseEndpointUrl, {
headers: { 'X-Session-Token': this.token },
});
eventSource.onerror = (error) => observer.error(error);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.zone.run(() => observer.next(data)); // ← back into Angular's zone
} catch (error) { Logger.log(error); observer.error(error); }
};
return () => { Logger.log('SseService - eventSource.close()'); eventSource.close(); };
});
}
}
Points worth knowing:
-
NgZone.runis required — polyfilledEventSourcecallbacks arrive outside Angular’s zone, so without it change detection never runs and the UI appears frozen. -
The token is captured from a long-lived store subscription, so a stream opened before login carries
undefined. -
Any
onerrorterminates the observable. There is no reconnect logic; the browser’s ownEventSourceretry is bypassed because the error is propagated as an observable error. -
A JSON parse failure also terminates the stream, not just the message.
-
Teardown closes the connection, so an unsubscribed stream is cleaned up.
|
A single malformed message or a transient network blip kills the stream permanently. The
UI will simply stop updating with no visible error unless the caller handles it. If you
depend on a stream staying alive, add your own retry ( |
The one consumer: B2B task monitoring
B2bWorkflowService (suppliers/services/workflow/) wraps the B2B task manager
(workflowApiUrl, correlated by workflowGciId):
taskMonitoring(): Observable<any> {
const endpointURL = `${this.apiUrl}tasksMonitoring`;
return !isUndefined(this.streamShared$) ? this.streamShared$ : this.initStream(endpointURL);
}
The stream is share()`d so that many subscribers use one connection, and each message is
also pushed onto an internal `Subject (handler).
subscribeTask(taskName, callback, searchForInclusion = false) filters that subject by
task name — exactly, or by regex search when searchForInclusion is true.
|
|
Other methods on the same service:
| Method | Detail |
|---|---|
|
|
|
|
|
|
|
Accept either a |
Who uses it
| Feature | Use |
|---|---|
Load Earnings / Load Remittance |
The supplier Actions menu operations return immediately; progress arrives on the stream. See lifecycle actions. |
Bulk imports |
|
2. The in-app event bus
src/app/shared/table-events.service.ts is a small Subject-based
publish/subscribe bus, used where a store action would be heavier than the problem
warrants:
export const ADD_TRANSACTION_EVENT = 'ADD_TRANSACTION_EVENT';
export const CRM_OPEN_ALL_TAB = 'CRM_OPEN_ALL_TAB';
export const SIDENAV_CLOSED_EVENT = 'SIDENAV_CLOSED_EVENT';
export const TASK_COUNTERS_UPDATE = 'TASK_COUNTERS_UPDATE';
@Injectable()
export class TableEventsService {
private listeners = {};
private events = new Subject();
constructor() {
this.events.subscribe(({ name, args }) => {
if (this.listeners[name]) { for (const l of this.listeners[name]) { l(...args); } }
});
}
on(name, listener) { … } // register
broadcast(name, ...args) { … } // emit
}
| Event | Broadcast by → consumed by |
|---|---|
|
|
|
|
|
Transaction dialogs → transaction grids refresh. |
|
Sidebar → layout listeners. |
|
Also note the constructor subscribes to |
Other Subject-based buses exist for narrower purposes:
| Service | Purpose |
|---|---|
|
Supplier cancellation / win-back change notifications. See lifecycle actions. |
|
Save/error feedback for the editable-card pattern. |
|
The iframe |
|
In-flight request count for the global spinner. |
3. Polling
Where neither SSE nor the bus applies, a few features poll.
| Feature | Mechanism |
|---|---|
Task counters |
|
Batch status |
|
Version check |
|
Choosing a mechanism
| Need | Use |
|---|---|
Progress of a long backend job |
|
"Refresh yourself" between sibling components |
|
Shared state more than one screen reads |
The NgRx store. See state management. |
Cross-application (EVA) notification |
The |
Do not add a new polling interval without checking whether the SSE stream already carries the information.
Gotchas summary
| Issue | Detail |
|---|---|
SSE has no reconnect |
Any error or parse failure ends the stream silently. |
|
Polyfilled callbacks are outside Angular’s zone. |
|
Nested subscription per received message. |
|
Listeners accumulate. |
A throwing listener kills the bus |
One shared |
Token captured by subscription |
A stream opened pre-login sends |