98 lines
2.5 KiB
TypeScript
98 lines
2.5 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|