Files
listify/listify-api/src/lists/list-realtime.service.ts
2026-06-10 14:35:18 +02:00

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);
}
}
}