Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/components/notifications/useNotificationsStream.ts
T

178 lines
7.7 KiB
TypeScript

// AK-P3 (AD-28/S5) — shared SSE subscription for in-app notifications.
// Hosts (routes/NavBar) own this hook and inject the resulting items into the
// presentational screens as props; screens themselves never subscribe (DoD A.5).
// Stream records are mapped through the same pure VM mapper as loader data so
// live updates and initial payloads share one shape (deeplink, bounce keys).
import { useEffect, useRef, useState } from "react";
import {
mergeNotificationCenterItems,
toNotificationCenterItemVM,
NOTIFICATION_CENTER_MAX_ITEMS,
type NotificationCenterItemVM,
} from "@hivegrid/view-models";
const RECONNECT_DELAY_MS = 1_000;
const MAX_RECONNECT_DELAY_MS = 30_000;
/**
* A connection only counts as healthy once it survived this long. Resetting the
* backoff on every `open`/message meant a stream that dropped right after the
* snapshot reconnected once per second forever instead of backing off.
*/
const STABLE_CONNECTION_MS = 15_000;
type StreamPayload = {
cursor?: unknown;
id?: unknown;
items?: unknown;
deliveries?: unknown;
item?: unknown;
delivery?: unknown;
notification?: unknown;
};
function serializeDateField(value: unknown): string | null {
if (value instanceof Date) return value.toISOString();
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function normalizeStreamItem(value: unknown): NotificationCenterItemVM | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
if (typeof record.id !== "string" || record.id.trim().length === 0) return null;
return toNotificationCenterItemVM({
id: record.id,
messageKey: typeof record.messageKey === "string" ? record.messageKey : "notification.delivery",
category: typeof record.category === "string" ? record.category : "notification",
channel: typeof record.channel === "string" ? record.channel : "IN_APP",
status: typeof record.status === "string" ? record.status : "PENDING",
attemptsMade: typeof record.attemptsMade === "number" && Number.isFinite(record.attemptsMade) ? record.attemptsMade : 0,
createdAtIso: serializeDateField(record.createdAt),
updatedAtIso: serializeDateField(record.updatedAt),
sentAtIso: serializeDateField(record.sentAt),
readAtIso: serializeDateField(record.readAt),
lastError: typeof record.lastError === "string" ? record.lastError : null,
payloadMetadata: typeof record.payloadMetadata === "object" ? record.payloadMetadata : undefined,
deeplink: typeof record.deeplink === "string" ? record.deeplink : null,
});
}
function getPayloadItems(payload: StreamPayload): NotificationCenterItemVM[] {
const snapshotItems = Array.isArray(payload.items) ? payload.items : Array.isArray(payload.deliveries) ? payload.deliveries : null;
if (snapshotItems) return snapshotItems.map(normalizeStreamItem).filter((item): item is NotificationCenterItemVM => Boolean(item));
const singleItem = normalizeStreamItem(payload.item ?? payload.delivery ?? payload.notification ?? payload);
return singleItem ? [singleItem] : [];
}
function getEventCursor(event: MessageEvent, payload: StreamPayload): string | null {
if (typeof payload.cursor === "string" && payload.cursor.trim().length > 0) return payload.cursor;
if (typeof payload.id === "string" && payload.id.trim().length > 0) return payload.id;
return event.lastEventId?.trim() ? event.lastEventId : null;
}
function parseStreamEvent(event: MessageEvent): { cursor: string | null; items: NotificationCenterItemVM[] } | null {
try {
const payload = JSON.parse(event.data || "{}");
if (!payload || typeof payload !== "object") return null;
return { cursor: getEventCursor(event, payload as StreamPayload), items: getPayloadItems(payload as StreamPayload) };
} catch {
return null;
}
}
function buildReconnectUrl(streamUrl: string, cursor: string | null) {
if (!cursor) return streamUrl;
const url = new URL(streamUrl, window.location.origin);
url.searchParams.set("afterCursor", cursor);
return url.toString();
}
export type NotificationsStreamState = {
items: NotificationCenterItemVM[];
unreadCount: number;
connectionStatusKey: string;
};
export function useNotificationsStream(
streamUrl: string | null | undefined,
initialItems: readonly NotificationCenterItemVM[]
): NotificationsStreamState {
const [items, setItems] = useState<NotificationCenterItemVM[]>(() => initialItems.slice(0, NOTIFICATION_CENTER_MAX_ITEMS));
const [connectionStatusKey, setConnectionStatusKey] = useState(
streamUrl ? "notifications.center.realtime.connecting" : "notifications.center.realtime.unavailable"
);
const cursorRef = useRef<string | null>(null);
const reconnectTimerRef = useRef<number | null>(null);
const reconnectDelayRef = useRef(RECONNECT_DELAY_MS);
const connectedAtRef = useRef<number | null>(null);
useEffect(() => {
// Revalidated loader data merges over the live state (never truncates it).
// merge(current, incoming): incoming wins per id — the fresh loader data
// must be the incoming side, otherwise stale live state shadows it.
setItems((currentItems) => mergeNotificationCenterItems(currentItems, initialItems.slice(0, NOTIFICATION_CENTER_MAX_ITEMS)));
}, [initialItems]);
useEffect(() => {
if (!streamUrl || typeof window === "undefined" || typeof window.EventSource === "undefined") return;
const activeStreamUrl = streamUrl;
let closed = false;
let source: EventSource | null = null;
function clearReconnectTimer() {
if (reconnectTimerRef.current) {
window.clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
}
function connect() {
clearReconnectTimer();
connectedAtRef.current = null;
source = new EventSource(buildReconnectUrl(activeStreamUrl, cursorRef.current));
setConnectionStatusKey("notifications.center.realtime.connecting");
function handleMessage(event: MessageEvent) {
const parsed = parseStreamEvent(event);
if (!parsed) return;
if (parsed.cursor && parsed.items.length > 0) cursorRef.current = parsed.cursor;
setItems((currentItems) => mergeNotificationCenterItems(currentItems, parsed.items));
setConnectionStatusKey("notifications.center.realtime.connected");
}
function handleOpen() {
connectedAtRef.current = Date.now();
setConnectionStatusKey("notifications.center.realtime.connected");
}
function handleError() {
if (closed) return;
source?.close();
setConnectionStatusKey("notifications.center.realtime.reconnecting");
// Only a connection that actually held counts as healthy. Otherwise a
// stream that dies right after opening keeps resetting the backoff and
// reconnects once per second forever.
const openedAt = connectedAtRef.current;
if (openedAt !== null && Date.now() - openedAt >= STABLE_CONNECTION_MS) {
reconnectDelayRef.current = RECONNECT_DELAY_MS;
}
connectedAtRef.current = null;
const reconnectDelay = reconnectDelayRef.current;
reconnectDelayRef.current = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY_MS);
reconnectTimerRef.current = window.setTimeout(connect, reconnectDelay);
}
source.addEventListener("open", handleOpen);
source.addEventListener("notification.snapshot", handleMessage);
source.addEventListener("notification.update", handleMessage);
source.addEventListener("error", handleError);
}
connect();
return () => {
closed = true;
clearReconnectTimer();
source?.close();
};
}, [streamUrl]);
return {
items,
unreadCount: items.filter((item) => !item.isRead).length,
connectionStatusKey,
};
}