feat(notes): push, long polling and fallback detection for note sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m16s

Phase 4 of the collaboration plan:
- The sync engine listens for DOC_SEQ in the note's realtime room and syncs
  right away. Without WebSocket/SSE it long-polls /docs/{id}/changes (25 s);
  after three failures it falls back to polling every 3 s and retries long
  polling when the browser comes back online.
- With push or long polling the engine only syncs every 7 s while holding
  leases (renewal), otherwise every 30 s as a safety net. Focusing a block
  announces its lease immediately (otherwise it could take up to 30 s).
- Realtime connection: SSE counts as connected only after its first event;
  a stream that stays silent for 5 s (buffering proxy) switches SSE off for
  the tab. App-level PING/PONG detects WebSockets that are accepted but
  forward nothing and switches to SSE after 35 s.
- The editor shows the active channel for shared notes.
- Tests: engine channel tests (push, heartbeat, lease on focus, long
  polling, interval fallback, switching), realtime fallback tests with fake
  WebSocket/EventSource, Go tests for PONG and DOC_SEQ delivery, and
  e2e/channels.e2e.mjs measuring latency behind six simulated proxy setups
  (0.2-0.3 s on all live channels, ~3 s with plain polling).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-28 14:10:29 +01:00
1 parent aa397c18cd
commit 8b7a53daab
18 files changed
+783 -37

No files matched your search

+50 -6
View File
@@ -1134,6 +1134,16 @@ export function onRealtimeStatus(callback: (connected: boolean, transport: Realt
}
let isWsBlockedByProxy = typeof window !== 'undefined' && sessionStorage.getItem('mindsift_ws_blocked') === 'true';
// Some proxies buffer event streams: the connection opens, but events only arrive much later (or never).
// Then SSE is useless and the note sync engines fall back to long polling.
let isSseBufferedByProxy = typeof window !== 'undefined' && sessionStorage.getItem('mindsift_sse_buffered') === 'true';
let sseFirstEventWatchdog: any = null;
const SSE_FIRST_EVENT_TIMEOUT_MS = 5000;
// Some proxies accept the WebSocket upgrade but then forward nothing. An app-level ping detects that.
let wsHeartbeatTimer: any = null;
let wsLastMessageAt = 0;
const WS_PING_INTERVAL_MS = 15000;
const WS_DEAD_AFTER_MS = 35000;
let sseReconnectTimer: any = null;
let sseReconnectDelay = 2000;
const globalSubscribedRooms = new Set<string>();
@@ -1153,6 +1163,7 @@ function notifyRealtimeStatusChange(connected: boolean, transport: RealtimeTrans
function switchToSSE(reason: string) {
console.warn(`[MindSift Realtime] ${reason} -> Schalte sofort und dauerhaft auf Server-Sent Events (SSE) um.`);
clearInterval(wsHeartbeatTimer);
isWsBlockedByProxy = true;
try {
sessionStorage.setItem('mindsift_ws_blocked', 'true');
@@ -1177,6 +1188,12 @@ export function connectSSE(rooms?: string[]): void {
clearTimeout(sseReconnectTimer);
clearTimeout(wsReconnectTimer);
clearTimeout(wsConnectionWatchdog);
clearTimeout(sseFirstEventWatchdog);
if (isSseBufferedByProxy) {
notifyRealtimeStatusChange(false, 'none');
return;
}
if (activeGlobalSSE) {
try { activeGlobalSSE.close(); } catch {}
@@ -1190,18 +1207,28 @@ export function connectSSE(rooms?: string[]): void {
es.onopen = () => {
sseReconnectDelay = 2000;
notifyRealtimeStatusChange(true, 'sse');
if (activeSSEId && globalSubscribedRooms.size > 0) {
globalSubscribedRooms.forEach((room) => {
api.joinSSERoom(activeSSEId!, room, 'join').catch(() => {});
});
}
// Connected only counts once the first event really arrives (see SSE_CONNECTED below)
clearTimeout(sseFirstEventWatchdog);
sseFirstEventWatchdog = setTimeout(() => {
console.warn('[MindSift Realtime] SSE liefert keine Ereignisse (Proxy puffert) -> Long-Polling');
isSseBufferedByProxy = true;
try {
sessionStorage.setItem('mindsift_sse_buffered', 'true');
} catch {}
try {
es.close();
} catch {}
if (activeGlobalSSE === es) activeGlobalSSE = null;
notifyRealtimeStatusChange(false, 'none');
}, SSE_FIRST_EVENT_TIMEOUT_MS);
};
es.onmessage = (event) => {
try {
const msg = JSON.parse(event.data) as WSMessage;
if (msg.event === 'SSE_CONNECTED') {
clearTimeout(sseFirstEventWatchdog);
notifyRealtimeStatusChange(true, 'sse');
activeSSEId = msg.payload?.sseId || null;
if (activeSSEId && globalSubscribedRooms.size > 0) {
globalSubscribedRooms.forEach((room) => {
@@ -1223,6 +1250,7 @@ export function connectSSE(rooms?: string[]): void {
};
es.onerror = (e) => {
clearTimeout(sseFirstEventWatchdog);
console.warn('[MindSift Realtime] SSE Verbindungsfehler:', e);
if (activeTransport === 'sse') {
notifyRealtimeStatusChange(false, 'none');
@@ -1279,6 +1307,17 @@ export function connectWebSocket(): void {
ws.onopen = () => {
clearTimeout(wsConnectionWatchdog);
wsLastMessageAt = Date.now();
clearInterval(wsHeartbeatTimer);
wsHeartbeatTimer = setInterval(() => {
if (Date.now() - wsLastMessageAt > WS_DEAD_AFTER_MS) {
switchToSSE('WebSocket liefert keine Antworten mehr (Proxy leitet nichts weiter)');
return;
}
try {
ws.send(JSON.stringify({ event: 'PING' }));
} catch {}
}, WS_PING_INTERVAL_MS);
if (activeGlobalSSE) {
try { activeGlobalSSE.close(); } catch {}
activeGlobalSSE = null;
@@ -1295,8 +1334,10 @@ export function connectWebSocket(): void {
};
ws.onmessage = (event) => {
wsLastMessageAt = Date.now();
try {
const msg = JSON.parse(event.data) as WSMessage;
if (msg.event === 'PONG') return;
wsListeners.forEach((fn) => {
try {
fn(msg);
@@ -1312,6 +1353,7 @@ export function connectWebSocket(): void {
ws.onclose = (evt: CloseEvent) => {
clearTimeout(wsConnectionWatchdog);
clearInterval(wsHeartbeatTimer);
activeGlobalWS = null;
// In corporate environments, blocked WebSockets close abruptly without clean handshake
@@ -1368,6 +1410,8 @@ if (typeof window !== 'undefined') {
}
export function disconnectWebSocket(): void {
clearInterval(wsHeartbeatTimer);
clearTimeout(sseFirstEventWatchdog);
manualDisconnect = true;
clearTimeout(wsReconnectTimer);
clearTimeout(wsConnectionWatchdog);