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
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
This commit is contained in:
1 parent
80bb74b5b2
commit
6343d62bab
10 files changed
+1402
-240
No files matched your search
@@ -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})` : ''}`
|
||||
}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user