sse für live collab eingebaut
This commit is contained in:
97
listify-api/src/lists/list-realtime.service.ts
Normal file
97
listify-api/src/lists/list-realtime.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Injectable, MessageEvent } from '@nestjs/common';
|
||||
import { Observable, Observer } from 'rxjs';
|
||||
import { UserList } from '../list-templates/list-template.types';
|
||||
|
||||
export type ListRealtimeEvent =
|
||||
| {
|
||||
type: 'list.snapshot';
|
||||
data: UserList;
|
||||
}
|
||||
| {
|
||||
type: 'list.deleted';
|
||||
data: { listId: string };
|
||||
}
|
||||
| {
|
||||
type: 'heartbeat';
|
||||
data: { at: string };
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ListRealtimeService {
|
||||
// In-memory SSE fanout for one API process. If the API is scaled horizontally,
|
||||
// replace this map with a shared pub/sub backend while keeping the event shape.
|
||||
private readonly channels = new Map<string, Set<Observer<ListRealtimeEvent>>>();
|
||||
|
||||
/**
|
||||
* Opens an owner-scoped stream. The controller authenticates the request before
|
||||
* calling this method, so subscribers only receive their own list events.
|
||||
*/
|
||||
eventsFor(ownerId: string): Observable<MessageEvent> {
|
||||
return new Observable<ListRealtimeEvent>((observer) => {
|
||||
this.addObserver(ownerId, observer);
|
||||
|
||||
// Keep proxies and browsers from treating an otherwise quiet stream as idle.
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
observer.next({
|
||||
type: 'heartbeat',
|
||||
data: { at: new Date().toISOString() },
|
||||
});
|
||||
}, 25_000);
|
||||
|
||||
return () => {
|
||||
clearInterval(heartbeatInterval);
|
||||
this.removeObserver(ownerId, observer);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
publishSnapshot(ownerId: string, list: UserList): void {
|
||||
this.publish(ownerId, {
|
||||
type: 'list.snapshot',
|
||||
data: list,
|
||||
});
|
||||
}
|
||||
|
||||
publishDeleted(ownerId: string, listId: string): void {
|
||||
this.publish(ownerId, {
|
||||
type: 'list.deleted',
|
||||
data: { listId },
|
||||
});
|
||||
}
|
||||
|
||||
private publish(ownerId: string, event: ListRealtimeEvent): void {
|
||||
const observers = this.channels.get(ownerId);
|
||||
|
||||
if (!observers) {
|
||||
return;
|
||||
}
|
||||
|
||||
observers.forEach((observer) => observer.next(event));
|
||||
}
|
||||
|
||||
private addObserver(
|
||||
ownerId: string,
|
||||
observer: Observer<ListRealtimeEvent>,
|
||||
): void {
|
||||
const observers = this.channels.get(ownerId) ?? new Set();
|
||||
observers.add(observer);
|
||||
this.channels.set(ownerId, observers);
|
||||
}
|
||||
|
||||
private removeObserver(
|
||||
ownerId: string,
|
||||
observer: Observer<ListRealtimeEvent>,
|
||||
): void {
|
||||
const observers = this.channels.get(ownerId);
|
||||
|
||||
if (!observers) {
|
||||
return;
|
||||
}
|
||||
|
||||
observers.delete(observer);
|
||||
|
||||
if (observers.size === 0) {
|
||||
this.channels.delete(ownerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user