feat(notes): push, long polling and fallback detection for note sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m16s
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:
1 parent
aa397c18cd
commit
8b7a53daab
18 files changed
+783
-37
No files matched your search
+50
-6
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user