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
@@ -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>
|
||||
|
||||
@@ -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})` : ''}`
|
||||
}
|
||||
>
|
||||
|
||||
+399
-23
@@ -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');
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user