fix(realtime): eliminate cpu reconnect loop, singleton global connection & fix disappearing notes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s

This commit is contained in:
sandrodach committed 2026-10-26 19:08:45 +01:00
1 parent 6343d62bab
commit f90b0d5eb9
6 files changed
+221 -294

No files matched your search

+138 -196
View File
@@ -1140,6 +1140,11 @@ export function onRealtimeStatus(callback: (connected: boolean, transport: Realt
};
}
let isWsBlockedByProxy = false;
let sseReconnectTimer: any = null;
let sseReconnectDelay = 2000;
const globalSubscribedRooms = new Set<string>();
function notifyRealtimeStatusChange(connected: boolean, transport: RealtimeTransport) {
isRealtimeConnectedState = connected;
activeTransport = transport;
@@ -1154,20 +1159,32 @@ function notifyRealtimeStatusChange(connected: boolean, transport: RealtimeTrans
}
export function connectSSE(rooms?: string[]): void {
if (rooms) {
rooms.forEach((r) => globalSubscribedRooms.add(r));
}
clearTimeout(sseReconnectTimer);
clearTimeout(wsReconnectTimer);
clearTimeout(wsConnectionWatchdog);
if (activeGlobalSSE) {
try { activeGlobalSSE.close(); } catch {}
activeGlobalSSE = null;
}
try {
const url = buildSSEURL(undefined, undefined, rooms);
const url = buildSSEURL(undefined, undefined, Array.from(globalSubscribedRooms));
const es = new EventSource(url);
activeGlobalSSE = es;
es.onopen = () => {
console.info('[MindSift Realtime] Server-Sent Events (SSE) verbunden (Proxy-Fallback aktiv).');
wsReconnectDelay = 2000;
sseReconnectDelay = 2000;
notifyRealtimeStatusChange(true, 'sse');
if (activeSSEId && globalSubscribedRooms.size > 0) {
globalSubscribedRooms.forEach((room) => {
api.joinSSERoom(activeSSEId!, room, 'join').catch(() => {});
});
}
};
es.onmessage = (event) => {
@@ -1175,6 +1192,11 @@ export function connectSSE(rooms?: string[]): void {
const msg = JSON.parse(event.data) as WSMessage;
if (msg.event === 'SSE_CONNECTED') {
activeSSEId = msg.payload?.sseId || null;
if (activeSSEId && globalSubscribedRooms.size > 0) {
globalSubscribedRooms.forEach((room) => {
api.joinSSERoom(activeSSEId!, room, 'join').catch(() => {});
});
}
}
wsListeners.forEach((fn) => {
try {
@@ -1190,17 +1212,24 @@ export function connectSSE(rooms?: string[]): void {
};
es.onerror = (e) => {
console.warn('[MindSift Realtime] SSE getrennt oder Fehler aufgetreten:', e);
if (activeTransport === 'sse') {
notifyRealtimeStatusChange(false, 'none');
}
try { es.close(); } catch {}
activeGlobalSSE = null;
if (!manualDisconnect && getToken()) {
clearTimeout(wsReconnectTimer);
wsReconnectTimer = setTimeout(() => {
connectWebSocket();
}, 5000);
clearTimeout(sseReconnectTimer);
const delay = sseReconnectDelay;
sseReconnectDelay = Math.min(30000, Math.round(sseReconnectDelay * 1.5));
sseReconnectTimer = setTimeout(() => {
if (!manualDisconnect && getToken()) {
if (isWsBlockedByProxy) {
connectSSE();
} else {
connectWebSocket();
}
}
}, delay);
}
};
} catch (err) {
@@ -1214,11 +1243,19 @@ export function connectWebSocket(): void {
if (!token) return;
manualDisconnect = false;
// If proxy blocked WebSocket previously, do not attempt failing WebSocket; stay on SSE
if (isWsBlockedByProxy) {
connectSSE();
return;
}
if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) {
return;
}
clearTimeout(wsConnectionWatchdog);
clearTimeout(wsReconnectTimer);
clearTimeout(sseReconnectTimer);
try {
const url = buildWebSocketURL();
@@ -1229,6 +1266,7 @@ export function connectWebSocket(): void {
wsConnectionWatchdog = setTimeout(() => {
if (ws && ws.readyState !== WebSocket.OPEN) {
console.warn('[MindSift Realtime] WebSocket connection timeout (Proxy Upgrade Block). Wechsle automatisch zu SSE...');
isWsBlockedByProxy = true;
try { ws.close(); } catch {}
activeGlobalWS = null;
connectSSE();
@@ -1243,6 +1281,13 @@ export function connectWebSocket(): void {
}
wsReconnectDelay = 2000;
notifyRealtimeStatusChange(true, 'ws');
if (globalSubscribedRooms.size > 0) {
globalSubscribedRooms.forEach((room) => {
try {
ws.send(JSON.stringify({ event: 'JOIN_ROOM', room }));
} catch {}
});
}
};
ws.onmessage = (event) => {
@@ -1264,11 +1309,11 @@ export function connectWebSocket(): void {
ws.onclose = (evt: CloseEvent) => {
clearTimeout(wsConnectionWatchdog);
activeGlobalWS = null;
console.warn('[MindSift Global WS] Closed with code:', evt?.code, 'reason:', evt?.reason);
const isProxyIssue = evt?.code === 1006 || evt?.code === 1002 || evt?.code === 1008;
if (isProxyIssue && !manualDisconnect && getToken()) {
console.warn('[MindSift Realtime] Proxy/Firewall Inkompatibilität erkannt (Code ' + evt.code + '). Wechsle zu SSE...');
console.warn('[MindSift Realtime] Proxy/Firewall Inkompatibilität erkannt (Code ' + evt.code + '). Wechsle dauerhaft zu SSE...');
isWsBlockedByProxy = true;
connectSSE();
return;
}
@@ -1282,21 +1327,22 @@ export function connectWebSocket(): void {
const delay = wsReconnectDelay;
wsReconnectDelay = Math.min(30000, Math.round(wsReconnectDelay * 1.5));
wsReconnectTimer = setTimeout(() => {
connectWebSocket();
if (!manualDisconnect && getToken()) {
connectWebSocket();
}
}, delay);
}
};
ws.onerror = (e) => {
clearTimeout(wsConnectionWatchdog);
console.warn('[MindSift Global WS] Error:', e);
try {
ws.close();
} catch {}
};
} catch (e) {
clearTimeout(wsConnectionWatchdog);
console.warn('Failed to connect WebSocket, switching to SSE:', e);
isWsBlockedByProxy = true;
connectSSE();
}
}
@@ -1305,12 +1351,20 @@ export function connectWebSocket(): void {
if (typeof window !== 'undefined') {
window.addEventListener('online', () => {
if (getToken() && !isRealtimeConnected()) {
connectWebSocket();
if (isWsBlockedByProxy) {
connectSSE();
} else {
connectWebSocket();
}
}
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && getToken() && !isRealtimeConnected()) {
connectWebSocket();
if (isWsBlockedByProxy) {
connectSSE();
} else {
connectWebSocket();
}
}
});
}
@@ -1319,6 +1373,7 @@ export function disconnectWebSocket(): void {
manualDisconnect = true;
clearTimeout(wsReconnectTimer);
clearTimeout(wsConnectionWatchdog);
clearTimeout(sseReconnectTimer);
if (activeGlobalWS) {
try {
activeGlobalWS.close();
@@ -1344,18 +1399,22 @@ export function sendWebSocketMessage(msg: any): void {
try {
const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg;
if (parsed.event === 'CRDT_UPDATE' && parsed.docId) {
api.saveDocumentCRDT(parsed.docId, { update: parsed.payload?.update });
api.saveDocumentCRDT(parsed.docId, { update: parsed.payload?.update }).catch(() => {});
} else if (parsed.event === 'CRDT_AWARENESS' && parsed.docId) {
api.sendDocumentAwareness(parsed.docId, parsed.payload);
api.sendDocumentAwareness(parsed.docId, parsed.payload).catch(() => {});
} else if (parsed.event === 'CRDT_LEAVE' && parsed.docId) {
api.clearDocumentAwareness(parsed.docId);
if (activeSSEId) api.joinSSERoom(activeSSEId, `doc:${parsed.docId}`, 'leave');
api.clearDocumentAwareness(parsed.docId).catch(() => {});
if (activeSSEId) api.joinSSERoom(activeSSEId, `doc:${parsed.docId}`, 'leave').catch(() => {});
} else if (parsed.event === 'CRDT_JOIN' && parsed.docId) {
if (activeSSEId) api.joinSSERoom(activeSSEId, `doc:${parsed.docId}`, 'join');
if (activeSSEId) api.joinSSERoom(activeSSEId, `doc:${parsed.docId}`, 'join').catch(() => {});
} else if (parsed.event === 'JOIN_ROOM' && parsed.room) {
if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'join').catch(() => {});
} else if (parsed.event === 'LEAVE_ROOM' && parsed.room) {
if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'leave').catch(() => {});
} else if (parsed.event === 'LOCK_DOC' && parsed.docId) {
api.lockDocument(parsed.docId);
api.lockDocument(parsed.docId).catch(() => {});
} else if (parsed.event === 'UNLOCK_DOC' && parsed.docId) {
api.unlockDocument(parsed.docId);
api.unlockDocument(parsed.docId).catch(() => {});
}
} catch (err) {
console.error('Error dispatching fallback REST message for SSE:', err);
@@ -1377,196 +1436,79 @@ export function createRealtimeConnection(options: {
onStatusChange?: (status: { connected: boolean; transport: RealtimeTransport; reason?: string }) => void;
}) {
let isClosed = false;
let currentTransport: RealtimeTransport = 'none';
let ws: WebSocket | null = null;
let es: EventSource | null = null;
let watchdogTimer: any = null;
let reconnectTimer: any = null;
let reconnectDelay = 2000;
let sseId: string | null = null;
const notify = (connected: boolean, transport: RealtimeTransport, reason?: string) => {
currentTransport = transport;
options.onStatusChange?.({ connected, transport, reason });
};
const joinedRooms: string[] = [];
if (options.docId) joinedRooms.push(`doc:${options.docId}`);
if (options.listId) joinedRooms.push(options.listId);
if (options.rooms) {
options.rooms.forEach((r) => {
if (!joinedRooms.includes(r)) joinedRooms.push(r);
});
}
const connectSSE = () => {
joinedRooms.forEach((r) => globalSubscribedRooms.add(r));
// Subscribe to status changes
const unsubStatus = onRealtimeStatus((connected, transport) => {
if (isClosed) return;
if (es) {
try { es.close(); } catch {}
es = null;
}
options.onStatusChange?.({
connected,
transport,
reason: connected ? undefined : 'Verbindung getrennt',
});
});
try {
const allRooms = [...(options.rooms || [])];
if (options.docId) allRooms.push(`doc:${options.docId}`);
if (options.listId) allRooms.push(options.listId);
const url = buildSSEURL(options.listId, options.docId, allRooms);
es = new EventSource(url);
// Subscribe to messages
let unsubMsg: (() => void) | null = null;
if (options.onMessage) {
unsubMsg = onWebSocketMessage((msg) => {
if (isClosed) return;
options.onMessage?.(msg);
});
}
es.onopen = () => {
if (isClosed) return;
reconnectDelay = 2000;
notify(true, 'sse');
};
es.onmessage = (evt) => {
if (isClosed) return;
try {
const msg = JSON.parse(evt.data) as WSMessage;
if (msg.event === 'SSE_CONNECTED') {
sseId = msg.payload?.sseId || null;
}
options.onMessage?.(msg);
} catch (e) {
console.error('SSE JSON error', e);
}
};
es.onerror = (e) => {
if (isClosed) return;
notify(false, 'none', 'SSE-Verbindungsabbruch');
try { es?.close(); } catch {}
es = null;
clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
if (!isClosed) connectWS();
}, 5000);
};
} catch (e: any) {
notify(false, 'none', e?.message || 'SSE Initialisierungsfehler');
}
// Join rooms on the active connection
const joinAll = () => {
joinedRooms.forEach((r) => {
if (r.startsWith('doc:')) {
sendWebSocketMessage({ event: 'CRDT_JOIN', docId: r.slice(4) });
} else {
sendWebSocketMessage({ event: 'JOIN_ROOM', room: r });
}
});
};
const connectWS = () => {
if (isRealtimeConnected()) {
joinAll();
}
const unsubRejoin = onRealtimeStatus((connected) => {
if (isClosed) return;
const token = getToken();
if (!token) {
notify(false, 'none', 'Kein Authentifizierungs-Token vorhanden');
return;
if (connected) {
joinAll();
}
if (ws) {
try { ws.close(); } catch {}
ws = null;
}
clearTimeout(watchdogTimer);
try {
const url = buildWebSocketURL(options.listId);
ws = new WebSocket(url);
// 3.5s watchdog for corporate proxies silently discarding Upgrade header
watchdogTimer = setTimeout(() => {
if (ws && ws.readyState !== WebSocket.OPEN && !isClosed) {
console.warn('[MindSift Realtime] WebSocket connection timeout (Proxy Upgrade block). Falling back to SSE...');
try { ws.close(); } catch {}
ws = null;
connectSSE();
}
}, 3500);
ws.onopen = () => {
if (isClosed) return;
clearTimeout(watchdogTimer);
reconnectDelay = 2000;
notify(true, 'ws');
if (options.docId) {
try {
ws?.send(JSON.stringify({ event: 'CRDT_JOIN', docId: options.docId }));
} catch {}
}
};
ws.onmessage = (evt) => {
if (isClosed) return;
try {
const msg = JSON.parse(evt.data) as WSMessage;
options.onMessage?.(msg);
} catch (e) {
console.error('WS JSON error', e);
}
};
ws.onclose = (evt: CloseEvent) => {
if (isClosed) return;
clearTimeout(watchdogTimer);
ws = null;
const isProxyError = evt?.code === 1006 || evt?.code === 1002 || evt?.code === 1008;
if (isProxyError) {
console.warn(`[MindSift Realtime] Proxy WebSocket rejection detected (Code ${evt.code}). Falling back to SSE...`);
connectSSE();
return;
}
const reason = evt.reason || (evt.code === 1000 ? 'Normal geschlossen' : `Code ${evt.code}`);
notify(false, 'none', reason);
clearTimeout(reconnectTimer);
const delay = reconnectDelay;
reconnectDelay = Math.min(30000, Math.round(reconnectDelay * 1.5));
reconnectTimer = setTimeout(connectWS, delay);
};
ws.onerror = (e) => {
if (isClosed) return;
clearTimeout(watchdogTimer);
try { ws?.close(); } catch {}
};
} catch (e: any) {
clearTimeout(watchdogTimer);
console.warn('[MindSift Realtime] WebSocket constructor failed, trying SSE fallback:', e);
connectSSE();
}
};
connectWS();
});
return {
getTransport: () => currentTransport,
isConnected: () => currentTransport !== 'none',
getTransport: () => activeTransport,
isConnected: () => isRealtimeConnected(),
send: (msg: any) => {
const str = typeof msg === 'string' ? msg : JSON.stringify(msg);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(str);
return;
}
// SSE Fallback: dispatch via REST endpoints
try {
const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg;
if (parsed.event === 'CRDT_UPDATE' && parsed.docId) {
api.saveDocumentCRDT(parsed.docId, { update: parsed.payload?.update });
} else if (parsed.event === 'CRDT_AWARENESS' && parsed.docId) {
api.sendDocumentAwareness(parsed.docId, parsed.payload);
} else if (parsed.event === 'CRDT_LEAVE' && parsed.docId) {
api.clearDocumentAwareness(parsed.docId);
if (sseId) api.joinSSERoom(sseId, `doc:${parsed.docId}`, 'leave');
} else if (parsed.event === 'CRDT_JOIN' && parsed.docId) {
if (sseId) api.joinSSERoom(sseId, `doc:${parsed.docId}`, 'join');
} else if (parsed.event === 'LOCK_DOC' && parsed.docId) {
api.lockDocument(parsed.docId);
} else if (parsed.event === 'UNLOCK_DOC' && parsed.docId) {
api.unlockDocument(parsed.docId);
}
} catch (err) {
console.error('Error dispatching fallback REST message for connection:', err);
}
if (isClosed) return;
sendWebSocketMessage(msg);
},
close: () => {
isClosed = true;
clearTimeout(watchdogTimer);
clearTimeout(reconnectTimer);
if (ws) {
try { ws.close(); } catch {}
ws = null;
}
if (es) {
try { es.close(); } catch {}
es = null;
}
notify(false, 'none');
unsubStatus();
unsubRejoin();
if (unsubMsg) unsubMsg();
joinedRooms.forEach((r) => {
globalSubscribedRooms.delete(r);
if (r.startsWith('doc:')) {
sendWebSocketMessage({ event: 'CRDT_LEAVE', docId: r.slice(4) });
} else {
sendWebSocketMessage({ event: 'LEAVE_ROOM', room: r });
}
});
},
};
}