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

+93 -79
View File
@@ -10,7 +10,7 @@ import {
Hash,
} from 'lucide-react';
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { useMobileActions } from '../../context/MobileActionContext';
@@ -86,6 +86,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const [isSubmittingItem, setIsSubmittingItem] = useState(false);
const [wsConnected, setWsConnected] = useState(false);
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>('none');
const [showNewListModal, setShowNewListModal] = useState(false);
const [newListTitle, setNewListTitle] = useState('');
const [newListIcon, setNewListIcon] = useState('🛒');
@@ -105,6 +106,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const [dragOverCategoryTarget, setDragOverCategoryTarget] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const realtimeConnRef = useRef<any>(null);
const loadLists = async () => {
try {
@@ -147,83 +149,79 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
useEffect(() => {
if (!activeList?.id) return;
if (wsRef.current) {
wsRef.current.close();
if (realtimeConnRef.current) {
try {
realtimeConnRef.current.close();
} catch {}
realtimeConnRef.current = null;
}
let ws: WebSocket | null = null;
try {
ws = new WebSocket(buildWebSocketURL(activeList.id));
wsRef.current = ws;
ws.onopen = () => {
setWsConnected(true);
};
ws.onclose = () => {
setWsConnected(false);
};
ws.onmessage = (evt) => {
try {
const msg: WSMessage = JSON.parse(evt.data);
const msgListId = msg.listId || (msg as any).list_id;
if (msg.event === 'ITEM_CREATED' && msgListId) {
const newItem: ListItem = msg.payload;
if (!newItem || !newItem.id) return;
setLists((prev) =>
prev.map((l) => {
if (l.id !== msgListId) return l;
if (l.items.some((i) => i.id === newItem.id)) return l;
return { ...l, items: deduplicateItems([newItem, ...l.items]) };
})
);
} else if (msg.event === 'ITEM_TOGGLED' && msgListId) {
const updatedItem: ListItem = msg.payload;
setLists((prev) =>
prev.map((l) => {
if (l.id !== msgListId) return l;
return {
...l,
items: l.items.map((i) => (i.id === updatedItem.id ? updatedItem : i)),
};
})
);
} else if (msg.event === 'ITEM_DELETED' && msgListId) {
const deletedId = msg.payload?.itemId;
setLists((prev) =>
prev.map((l) => {
if (l.id !== msgListId) return l;
return { ...l, items: l.items.filter((i) => i.id !== deletedId) };
})
);
} else if (msg.event === 'LIST_CLEARED' && msgListId) {
setLists((prev) =>
prev.map((l) => {
if (l.id !== msgListId) return l;
return { ...l, items: l.items.filter((i) => !i.isCompleted) };
})
);
} else if (
msg.event === 'LIST_CREATED' ||
msg.event === 'LIST_DELETED' ||
msg.event === 'LIST_SHARED' ||
msg.event === 'LIST_UNSHARED' ||
msg.event === 'ITEM_REORDERED' ||
msg.event === 'LIST_UPDATED'
) {
loadLists();
}
} catch {
// ignore malformed frames
const conn = createRealtimeConnection({
listId: activeList.id,
onStatusChange: (status) => {
setWsConnected(status.connected);
setRealtimeTransport(status.transport);
},
onMessage: (msg: WSMessage) => {
const msgListId = msg.listId || (msg as any).list_id;
if (msg.event === 'ITEM_CREATED' && msgListId) {
const newItem: ListItem = msg.payload;
if (!newItem || !newItem.id) return;
setLists((prev) =>
prev.map((l) => {
if (l.id !== msgListId) return l;
if (l.items.some((i) => i.id === newItem.id)) return l;
return { ...l, items: deduplicateItems([newItem, ...l.items]) };
})
);
} else if (msg.event === 'ITEM_TOGGLED' && msgListId) {
const updatedItem: ListItem = msg.payload;
setLists((prev) =>
prev.map((l) => {
if (l.id !== msgListId) return l;
return {
...l,
items: l.items.map((i) => (i.id === updatedItem.id ? updatedItem : i)),
};
})
);
} else if (msg.event === 'ITEM_DELETED' && msgListId) {
const deletedId = msg.payload?.itemId;
setLists((prev) =>
prev.map((l) => {
if (l.id !== msgListId) return l;
return { ...l, items: l.items.filter((i) => i.id !== deletedId) };
})
);
} else if (msg.event === 'LIST_CLEARED' && msgListId) {
setLists((prev) =>
prev.map((l) => {
if (l.id !== msgListId) return l;
return { ...l, items: l.items.filter((i) => !i.isCompleted) };
})
);
} else if (
msg.event === 'LIST_CREATED' ||
msg.event === 'LIST_DELETED' ||
msg.event === 'LIST_SHARED' ||
msg.event === 'LIST_UNSHARED' ||
msg.event === 'ITEM_REORDERED' ||
msg.event === 'LIST_UPDATED'
) {
loadLists();
}
};
} catch {
setWsConnected(false);
}
},
});
realtimeConnRef.current = conn;
return () => {
if (ws) ws.close();
if (realtimeConnRef.current) {
try {
realtimeConnRef.current.close();
} catch {}
realtimeConnRef.current = null;
}
};
}, [activeList?.id]);
@@ -821,12 +819,20 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</button>
)}
<div
className={`h-9 w-9 flex items-center justify-center rounded-btn border ${
className={`h-9 w-9 flex items-center justify-center rounded-btn border transition-colors ${
wsConnected
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
? realtimeTransport === 'sse'
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
: 'bg-canvas border-border text-muted'
}`}
title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline'}
title={
wsConnected
? realtimeTransport === 'sse'
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
: 'Live synchronisiert (WebSocket online)'
: 'Offline'
}
>
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
</div>
@@ -927,12 +933,20 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
{/* Live Sync Status */}
<div
className={`h-8 w-8 flex items-center justify-center rounded-btn border ${
className={`h-8 w-8 flex items-center justify-center rounded-btn border transition-colors ${
wsConnected
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
? realtimeTransport === 'sse'
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
: 'bg-canvas border-border text-muted'
}`}
title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline'}
title={
wsConnected
? realtimeTransport === 'sse'
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
: 'Live synchronisiert (WebSocket online)'
: 'Offline'
}
>
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
</div>
+83 -106
View File
@@ -50,7 +50,7 @@ import {
Radio,
} from 'lucide-react';
import { BlockType, Document, DocumentBlock, DocBlocksPatchEvent, DocLockInfo, DocShare, FolderShare, SharedFolder, TagDetails, TagSummary, Team, User } from '../../lib/types';
import { api, getToken, triggerHaptic, onWebSocketStatus, isWebSocketConnected, buildWebSocketURL } from '../../lib/api';
import { api, getToken, triggerHaptic, onWebSocketStatus, isWebSocketConnected, buildWebSocketURL, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
import { CodeBlockItem } from './CodeBlockItem';
import { UserAutocomplete } from '../UserAutocomplete';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
@@ -130,11 +130,13 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [showIconPicker, setShowIconPicker] = useState(false);
const [showWikiLinkMenu, setShowWikiLinkMenu] = useState(false);
// Dedicated WebSocket connection & status for live indicator
// Dedicated WebSocket/SSE connection & status for live indicator
const wsRef = useRef<WebSocket | null>(null);
const realtimeConnRef = useRef<any>(null);
const wsReconnectTimerRef = useRef<any>(null);
const wsReconnectDelayRef = useRef<number>(2000);
const [wsConnected, setWsConnected] = useState<boolean>(false);
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>('none');
const [wsDisconnectReason, setWsDisconnectReason] = useState<string | null>(null);
// Folder state (synchronized with server)
@@ -431,22 +433,33 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
};
const broadcastAwareness = (blockId: string | null) => {
if (!selectedDoc || !wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
try {
wsRef.current.send(
JSON.stringify({
event: 'CRDT_AWARENESS',
docId: selectedDoc.id,
payload: {
userId: currentUser?.id,
userName: currentUser?.displayName || currentUser?.username || 'Kollege',
activeBlockId: blockId,
color: getCollaboratorColor(currentUser?.id || ''),
lastActive: Date.now(),
},
})
);
} catch {}
if (!selectedDoc) return;
const payload = {
userId: currentUser?.id,
userName: currentUser?.displayName || currentUser?.username || 'Kollege',
activeBlockId: blockId,
color: getCollaboratorColor(currentUser?.id || ''),
lastActive: Date.now(),
};
if (realtimeConnRef.current && realtimeConnRef.current.isConnected()) {
realtimeConnRef.current.send({
event: 'CRDT_AWARENESS',
docId: selectedDoc.id,
payload,
});
} else if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
try {
wsRef.current.send(
JSON.stringify({
event: 'CRDT_AWARENESS',
docId: selectedDoc.id,
payload,
})
);
} catch {}
} else {
api.sendDocumentAwareness(selectedDoc.id, payload).catch(() => {});
}
};
const handleBlockFocus = (blockIdOrEvent?: string | React.FocusEvent<any>) => {
@@ -779,10 +792,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
if (prevId && prevId !== selectedDoc?.id) {
flushPendingSave();
releaseMyLock(prevId);
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
if (realtimeConnRef.current && realtimeConnRef.current.isConnected()) {
realtimeConnRef.current.send({ event: 'CRDT_LEAVE', docId: prevId });
} else if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
try {
wsRef.current.send(JSON.stringify({ event: 'CRDT_LEAVE', docId: prevId }));
} catch {}
} else {
api.clearDocumentAwareness(prevId).catch(() => {});
}
if (crdtSessionRef.current) {
crdtSessionRef.current.destroy();
@@ -797,7 +814,13 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const session = new DocumentCRDTSession(targetDoc.id);
session.initFromBlocks(targetDoc.blocks || []);
session.onLocalUpdate = (b64Update) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
if (realtimeConnRef.current && realtimeConnRef.current.isConnected()) {
realtimeConnRef.current.send({
event: 'CRDT_UPDATE',
docId: targetDoc.id,
payload: { update: b64Update },
});
} else if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
try {
wsRef.current.send(
JSON.stringify({
@@ -807,6 +830,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
})
);
} catch {}
} else {
api.saveDocumentCRDT(targetDoc.id, { update: b64Update }).catch(() => {});
}
};
session.onRemoteChange = (crdtBlocks) => {
@@ -815,7 +840,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
};
crdtSessionRef.current = session;
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
if (realtimeConnRef.current && realtimeConnRef.current.isConnected()) {
realtimeConnRef.current.send({ event: 'CRDT_JOIN', docId: targetDoc.id });
} else if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
try {
wsRef.current.send(JSON.stringify({ event: 'CRDT_JOIN', docId: targetDoc.id }));
} catch {}
@@ -857,10 +884,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return () => {
flushPendingSave();
if (currentDocIdRef.current) {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
if (realtimeConnRef.current && realtimeConnRef.current.isConnected()) {
realtimeConnRef.current.send({ event: 'CRDT_LEAVE', docId: currentDocIdRef.current });
} else if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
try {
wsRef.current.send(JSON.stringify({ event: 'CRDT_LEAVE', docId: currentDocIdRef.current }));
} catch {}
} else {
api.clearDocumentAwareness(currentDocIdRef.current).catch(() => {});
}
if (crdtSessionRef.current) {
crdtSessionRef.current.destroy();
@@ -1097,87 +1128,26 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
};
const connect = () => {
if (!isMounted) return;
const token = getToken();
if (!token) {
setWsConnected(false);
return;
}
if (wsRef.current) {
try {
wsRef.current.close();
} catch {}
wsRef.current = null;
}
try {
const url = buildWebSocketURL();
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
if (!isMounted) return;
wsReconnectDelayRef.current = 2000;
const conn = createRealtimeConnection({
docId: selectedDocIdRef.current || undefined,
onMessage: handleWsMsg,
onStatusChange: (status) => {
if (!isMounted) return;
setWsConnected(status.connected);
setRealtimeTransport(status.transport);
if (status.connected) {
setWsDisconnectReason(null);
setWsConnected(true);
if (selectedDocIdRef.current) {
try {
ws.send(JSON.stringify({ event: 'CRDT_JOIN', docId: selectedDocIdRef.current }));
} catch {}
conn.send({ event: 'CRDT_JOIN', docId: selectedDocIdRef.current });
}
api.getFolders().then(setCustomFolders).catch(() => {});
api.getSharedFolders().then(setSharedFolders).catch(() => {});
};
ws.onclose = (evt: CloseEvent) => {
if (!isMounted) return;
setWsConnected(false);
const reasonText =
evt.reason ||
(evt.code === 1006
? 'Verbindung durch Firmen-Proxy oder Netzwerk getrennt (Code 1006)'
: evt.code === 1008
? 'Richtlinien-Verletzung durch Proxy/Firewall (Code 1008)'
: evt.code === 1002
? 'Protokollfehler beim Handshake (Code 1002)'
: evt.code === 1000
? 'Normal geschlossen (Code 1000)'
: `Code ${evt.code}`);
console.warn('[MindSift Workspace WS] getrennt:', evt.code, evt.reason || reasonText);
setWsDisconnectReason(reasonText);
clearTimeout(wsReconnectTimerRef.current);
const delay = wsReconnectDelayRef.current;
wsReconnectDelayRef.current = Math.min(30000, Math.round(wsReconnectDelayRef.current * 1.5));
wsReconnectTimerRef.current = setTimeout(connect, delay);
};
ws.onerror = (e) => {
if (!isMounted) return;
console.warn('[MindSift Workspace WS] Fehler aufgetreten:', e);
setWsConnected(false);
};
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
handleWsMsg(msg);
} catch {}
};
} catch (err: any) {
if (!isMounted) return;
setWsConnected(false);
const errText = err?.message || 'Verbindungsfehler';
setWsDisconnectReason(errText);
clearTimeout(wsReconnectTimerRef.current);
const delay = wsReconnectDelayRef.current;
wsReconnectDelayRef.current = Math.min(30000, Math.round(wsReconnectDelayRef.current * 1.5));
wsReconnectTimerRef.current = setTimeout(connect, delay);
}
};
connect();
} else {
setWsDisconnectReason(status.reason || 'Verbindung getrennt');
}
},
});
realtimeConnRef.current = conn;
// Also synchronize with global WebSocket status
const unsubGlobalStatus = onWebSocketStatus((connected) => {
@@ -1199,12 +1169,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return () => {
isMounted = false;
unsubGlobalStatus();
clearTimeout(wsReconnectTimerRef.current);
if (wsRef.current) {
if (realtimeConnRef.current) {
try {
wsRef.current.close();
realtimeConnRef.current.close();
} catch {}
wsRef.current = null;
realtimeConnRef.current = null;
}
window.removeEventListener('mindsift:ws', handleGlobalCustomEvent);
};
@@ -4058,12 +4027,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<div
className={`h-8 w-8 flex items-center justify-center rounded-btn border transition-colors ${
wsConnected
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
? realtimeTransport === 'sse'
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
: 'bg-amber-500/10 border-amber-500/30 text-amber-500'
}`}
title={
wsConnected
? 'Live synchronisiert (WebSocket online)'
? realtimeTransport === 'sse'
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
: 'Live synchronisiert (WebSocket online)'
: `Proxy/Offline-Fallback: Synchronisation über HTTP aktiv${wsDisconnectReason ? ` (${wsDisconnectReason})` : ''}`
}
>
@@ -4098,12 +4071,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<div
className={`h-9 w-9 flex items-center justify-center rounded-btn border transition-colors ${
wsConnected
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
? realtimeTransport === 'sse'
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
: 'bg-amber-500/10 border-amber-500/30 text-amber-500'
}`}
title={
wsConnected
? 'Live synchronisiert (WebSocket online)'
? realtimeTransport === 'sse'
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
: 'Live synchronisiert (WebSocket online)'
: `Proxy/Offline-Fallback: Synchronisation über HTTP aktiv${wsDisconnectReason ? ` (${wsDisconnectReason})` : ''}`
}
>