feat(realtime): implement SSE fallback, dual-transport hub & proxy resilience (resolves FLAW-03)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s

This commit is contained in:
sandrodach committed 2026-10-26 16:55:11 +01:00
1 parent 80bb74b5b2
commit 6343d62bab
10 files changed
+1402 -240

No files matched your search

+399 -23
View File
@@ -606,10 +606,31 @@ export const api = {
return request<{ docId: string; state?: string; updates?: string[] }>(`/api/v1/documents/${id}/crdt`);
},
async saveDocumentCRDT(id: string, state: string): Promise<{ success: boolean }> {
async saveDocumentCRDT(id: string, payload: string | { state?: string; update?: string }): Promise<{ success: boolean }> {
const body = typeof payload === 'string' ? { state: payload } : payload;
return request<{ success: boolean }>(`/api/v1/documents/${id}/crdt`, {
method: 'PUT',
body: JSON.stringify({ state }),
body: JSON.stringify(body),
});
},
async sendDocumentAwareness(id: string, payload: any): Promise<{ success: boolean }> {
return request<{ success: boolean }>(`/api/v1/documents/${id}/awareness`, {
method: 'POST',
body: JSON.stringify(payload),
});
},
async clearDocumentAwareness(id: string): Promise<{ success: boolean }> {
return request<{ success: boolean }>(`/api/v1/documents/${id}/awareness`, {
method: 'DELETE',
});
},
async joinSSERoom(sseId: string, room: string, action: 'join' | 'leave' = 'join'): Promise<{ success: boolean }> {
return request<{ success: boolean }>('/api/v1/events/rooms', {
method: 'POST',
body: JSON.stringify({ sseId, room, action }),
});
},
@@ -1053,11 +1074,17 @@ export function getOfflineQueue(): Thought[] {
}
}
export type RealtimeTransport = 'ws' | 'sse' | 'none';
let activeTransport: RealtimeTransport = 'none';
let activeGlobalWS: WebSocket | null = null;
let activeGlobalSSE: EventSource | null = null;
let activeSSEId: string | null = null;
let wsReconnectTimer: any = null;
let wsConnectionWatchdog: any = null;
let wsListeners: ((msg: WSMessage) => void)[] = [];
let wsStatusListeners: ((connected: boolean) => void)[] = [];
let isWSConnected = false;
let realtimeStatusListeners: ((connected: boolean, transport: RealtimeTransport) => void)[] = [];
let isRealtimeConnectedState = false;
let manualDisconnect = false;
let wsReconnectDelay = 2000;
@@ -1070,28 +1097,116 @@ export function buildWebSocketURL(listId?: string): string {
return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`;
}
export function buildSSEURL(listId?: string, docId?: string, rooms?: string[]): string {
const token = getToken() || '';
const params = new URLSearchParams();
if (token) params.set('token', token);
if (listId) params.set('listId', listId);
if (docId) params.set('docId', docId);
if (rooms && rooms.length > 0) params.set('rooms', rooms.join(','));
return `/api/v1/events?${params.toString()}`;
}
export function isWebSocketConnected(): boolean {
return isWSConnected && activeGlobalWS !== null && activeGlobalWS.readyState === WebSocket.OPEN;
return activeTransport === 'ws' && activeGlobalWS !== null && activeGlobalWS.readyState === WebSocket.OPEN;
}
export function isRealtimeConnected(): boolean {
return isRealtimeConnectedState;
}
export function getRealtimeTransport(): RealtimeTransport {
return activeTransport;
}
export function getSSEId(): string | null {
return activeSSEId;
}
export function onWebSocketStatus(callback: (connected: boolean) => void): () => void {
wsStatusListeners.push(callback);
callback(isWebSocketConnected());
const wrapper = (connected: boolean) => callback(connected);
realtimeStatusListeners.push(wrapper);
callback(isRealtimeConnected());
return () => {
wsStatusListeners = wsStatusListeners.filter((fn) => fn !== callback);
realtimeStatusListeners = realtimeStatusListeners.filter((fn) => fn !== wrapper);
};
}
function notifyStatusChange(connected: boolean) {
isWSConnected = connected;
wsStatusListeners.forEach((fn) => {
export function onRealtimeStatus(callback: (connected: boolean, transport: RealtimeTransport) => void): () => void {
realtimeStatusListeners.push(callback);
callback(isRealtimeConnected(), activeTransport);
return () => {
realtimeStatusListeners = realtimeStatusListeners.filter((fn) => fn !== callback);
};
}
function notifyRealtimeStatusChange(connected: boolean, transport: RealtimeTransport) {
isRealtimeConnectedState = connected;
activeTransport = transport;
realtimeStatusListeners.forEach((fn) => {
try {
fn(connected);
fn(connected, transport);
} catch (e) {
console.error('WS status listener error', e);
console.error('Realtime status listener error', e);
}
});
window.dispatchEvent(new CustomEvent('mindsift:ws:status', { detail: { connected } }));
window.dispatchEvent(new CustomEvent('mindsift:ws:status', { detail: { connected, transport } }));
}
export function connectSSE(rooms?: string[]): void {
if (activeGlobalSSE) {
try { activeGlobalSSE.close(); } catch {}
activeGlobalSSE = null;
}
try {
const url = buildSSEURL(undefined, undefined, rooms);
const es = new EventSource(url);
activeGlobalSSE = es;
es.onopen = () => {
console.info('[MindSift Realtime] Server-Sent Events (SSE) verbunden (Proxy-Fallback aktiv).');
wsReconnectDelay = 2000;
notifyRealtimeStatusChange(true, 'sse');
};
es.onmessage = (event) => {
try {
const msg = JSON.parse(event.data) as WSMessage;
if (msg.event === 'SSE_CONNECTED') {
activeSSEId = msg.payload?.sseId || null;
}
wsListeners.forEach((fn) => {
try {
fn(msg);
} catch (e) {
console.error('SSE listener error', e);
}
});
window.dispatchEvent(new CustomEvent('mindsift:ws', { detail: msg }));
} catch (err) {
console.error('Failed to parse SSE message', err);
}
};
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);
}
};
} catch (err) {
console.error('Failed to initialize EventSource SSE', err);
notifyRealtimeStatusChange(false, 'none');
}
}
export function connectWebSocket(): void {
@@ -1103,14 +1218,31 @@ export function connectWebSocket(): void {
return;
}
clearTimeout(wsConnectionWatchdog);
try {
const url = buildWebSocketURL();
const ws = new WebSocket(url);
activeGlobalWS = ws;
// 3.5s watchdog for corporate proxies silently stripping Upgrade header
wsConnectionWatchdog = setTimeout(() => {
if (ws && ws.readyState !== WebSocket.OPEN) {
console.warn('[MindSift Realtime] WebSocket connection timeout (Proxy Upgrade Block). Wechsle automatisch zu SSE...');
try { ws.close(); } catch {}
activeGlobalWS = null;
connectSSE();
}
}, 3500);
ws.onopen = () => {
clearTimeout(wsConnectionWatchdog);
if (activeGlobalSSE) {
try { activeGlobalSSE.close(); } catch {}
activeGlobalSSE = null;
}
wsReconnectDelay = 2000;
notifyStatusChange(true);
notifyRealtimeStatusChange(true, 'ws');
};
ws.onmessage = (event) => {
@@ -1130,10 +1262,22 @@ 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);
notifyStatusChange(false);
if (!manualDisconnect && getToken()) {
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...');
connectSSE();
return;
}
if (activeTransport === 'ws') {
notifyRealtimeStatusChange(false, 'none');
}
if (!manualDisconnect && getToken() && activeTransport !== 'sse') {
clearTimeout(wsReconnectTimer);
const delay = wsReconnectDelay;
wsReconnectDelay = Math.min(30000, Math.round(wsReconnectDelay * 1.5));
@@ -1144,27 +1288,28 @@ export function connectWebSocket(): void {
};
ws.onerror = (e) => {
clearTimeout(wsConnectionWatchdog);
console.warn('[MindSift Global WS] Error:', e);
notifyStatusChange(false);
try {
ws.close();
} catch {}
};
} catch (e) {
console.error('Failed to connect WebSocket', e);
notifyStatusChange(false);
clearTimeout(wsConnectionWatchdog);
console.warn('Failed to connect WebSocket, switching to SSE:', e);
connectSSE();
}
}
// Ensure reconnect when browser recovers from sleep or tab visibility
if (typeof window !== 'undefined') {
window.addEventListener('online', () => {
if (getToken() && !isWebSocketConnected()) {
if (getToken() && !isRealtimeConnected()) {
connectWebSocket();
}
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && getToken() && !isWebSocketConnected()) {
if (document.visibilityState === 'visible' && getToken() && !isRealtimeConnected()) {
connectWebSocket();
}
});
@@ -1173,18 +1318,47 @@ if (typeof window !== 'undefined') {
export function disconnectWebSocket(): void {
manualDisconnect = true;
clearTimeout(wsReconnectTimer);
clearTimeout(wsConnectionWatchdog);
if (activeGlobalWS) {
try {
activeGlobalWS.close();
} catch {}
activeGlobalWS = null;
}
notifyStatusChange(false);
if (activeGlobalSSE) {
try {
activeGlobalSSE.close();
} catch {}
activeGlobalSSE = null;
}
notifyRealtimeStatusChange(false, 'none');
}
export function sendWebSocketMessage(msg: any): void {
if (activeGlobalWS && activeGlobalWS.readyState === WebSocket.OPEN) {
activeGlobalWS.send(typeof msg === 'string' ? msg : JSON.stringify(msg));
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 (activeSSEId) api.joinSSERoom(activeSSEId, `doc:${parsed.docId}`, 'leave');
} else if (parsed.event === 'CRDT_JOIN' && parsed.docId) {
if (activeSSEId) api.joinSSERoom(activeSSEId, `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 SSE:', err);
}
}
@@ -1194,3 +1368,205 @@ export function onWebSocketMessage(callback: (msg: WSMessage) => void): () => vo
wsListeners = wsListeners.filter((fn) => fn !== callback);
};
}
export function createRealtimeConnection(options: {
listId?: string;
docId?: string;
rooms?: string[];
onMessage?: (msg: WSMessage) => void;
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 connectSSE = () => {
if (isClosed) return;
if (es) {
try { es.close(); } catch {}
es = null;
}
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);
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');
}
};
const connectWS = () => {
if (isClosed) return;
const token = getToken();
if (!token) {
notify(false, 'none', 'Kein Authentifizierungs-Token vorhanden');
return;
}
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',
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);
}
},
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');
},
};
}