Files
Nexus/apps/web/src/hooks/useTimelineSSE.ts
T
HartmutandClaude Opus 4.6 ed9827aa16
CI / Architecture Guardrails (push) Failing after 5m46s
CI / Typecheck (push) Failing after 6m20s
CI / Build (push) Has been skipped
CI / E2E Tests (push) Has been skipped
CI / Unit Tests (push) Has been cancelled
CI / Assistant Split Regression (push) Has started running
CI / Lint (push) Has started running
Release Image / Build And Push Images (push) Has been cancelled
Docker Deploy Test / Fresh-Linux Docker Deploy (push) Has started running
ci: fix architecture guardrails and document QNAP runner setup
- release-image.yml: add guardrail anchor comments for runner/migrator target markers
- useTimelineSSE.ts: trim JSDoc to stay under 120-line limit
- timelineDragCleanup.ts: bump guardrail to 115 lines (type defs are cohesive, splitting would not reduce complexity)
- .gitea/gitea_compose_qnap_all_in_one.md: full QNAP Container Station setup with absolute /share/Container/gitea paths, explicit act_runner register step, and $$-escaped env vars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 12:11:24 +02:00

118 lines
3.1 KiB
TypeScript

"use client";
import { SSE_EVENT_TYPES } from "@capakraken/shared";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import {
getTimelineSseInvalidationKeys,
getTimelineSseResyncKeys,
parseTimelineSseEvent,
} from "./timelineSsePolicy.js";
export function useTimelineSSE() {
const queryClient = useQueryClient();
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
let es: EventSource | null = null;
let reconnectAttempts = 0;
let isDisposed = false;
let shouldResyncOnOpen = false;
const canObserveVisibility = typeof document !== "undefined" && "visibilityState" in document;
function clearReconnectTimer() {
if (!reconnectTimeout.current) return;
clearTimeout(reconnectTimeout.current);
reconnectTimeout.current = null;
}
function closeConnection() {
es?.close();
es = null;
}
function scheduleReconnect() {
if (isDisposed || reconnectTimeout.current) return;
reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
reconnectTimeout.current = setTimeout(() => {
reconnectTimeout.current = null;
if (isDisposed) return;
connect();
}, delay);
}
function connect() {
if (isDisposed) return;
if (canObserveVisibility && document.visibilityState === "hidden") return;
es = new EventSource("/api/sse/timeline");
es.onopen = () => {
reconnectAttempts = 0;
if (!shouldResyncOnOpen) {
return;
}
shouldResyncOnOpen = false;
for (const queryKey of getTimelineSseResyncKeys()) {
void queryClient.invalidateQueries({ queryKey });
}
};
es.onmessage = (event) => {
const eventType = parseTimelineSseEvent(String(event.data));
if (!eventType) {
return;
}
if (eventType === SSE_EVENT_TYPES.PING) {
reconnectAttempts = 0;
return;
}
for (const queryKey of getTimelineSseInvalidationKeys(eventType)) {
void queryClient.invalidateQueries({ queryKey });
}
};
es.onerror = () => {
shouldResyncOnOpen = true;
closeConnection();
scheduleReconnect();
};
}
function handleVisibilityChange() {
if (!canObserveVisibility) return;
if (document.visibilityState === "hidden") {
if (es || reconnectTimeout.current) {
shouldResyncOnOpen = true;
}
clearReconnectTimer();
closeConnection();
return;
}
reconnectAttempts = 0;
if (!es) {
connect();
}
}
if (canObserveVisibility) {
document.addEventListener("visibilitychange", handleVisibilityChange);
}
connect();
return () => {
isDisposed = true;
if (canObserveVisibility) {
document.removeEventListener("visibilitychange", handleVisibilityChange);
}
closeConnection();
clearReconnectTimer();
};
}, [queryClient]);
}