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
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s
This commit is contained in:
1 parent
6343d62bab
commit
f90b0d5eb9
6 files changed
+221
-294
No files matched your search
+138
-196
@@ -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 });
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user