feat(realtime): long polling for app-wide events behind proxies

When a proxy lets neither WebSocket nor SSE through, the app opens a
poll connection: the server buffers its events (an SSEClient without a
stream), each GET waits up to 25 s, and room joins work like for SSE.
Tasks and lists now update live there too, and note sync takes its
notifications from it instead of a second long poll. Idle connections
close after two minutes. The profile shows such a connection as limited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-02 11:50:27 +01:00
1 parent c19d8fd03e
commit 2b7356d043
16 files changed
+369 -20

No files matched your search

+77 -2
View File
@@ -1464,7 +1464,8 @@ export const api = {
};
export type RealtimeTransport = 'ws' | 'sse' | 'none';
// poll: long polling, when a proxy lets neither WebSocket nor event streams through
export type RealtimeTransport = 'ws' | 'sse' | 'poll' | 'none';
let activeTransport: RealtimeTransport = 'none';
let activeGlobalWS: WebSocket | null = null;
@@ -1599,7 +1600,7 @@ export async function connectSSE(rooms?: string[]): Promise<void> {
clearTimeout(sseFirstEventWatchdog);
if (isSseBufferedByProxy) {
notifyRealtimeStatusChange(false, 'none');
void connectPoll();
return;
}
@@ -1640,6 +1641,7 @@ export async function connectSSE(rooms?: string[]): Promise<void> {
} catch {}
if (activeGlobalSSE === es) activeGlobalSSE = null;
notifyRealtimeStatusChange(false, 'none');
void connectPoll();
}, SSE_FIRST_EVENT_TIMEOUT_MS);
};
@@ -1694,6 +1696,78 @@ export async function connectSSE(rooms?: string[]): Promise<void> {
}
}
// Long polling: the server buffers the events of a poll connection; each request waits until there are
// some (at most POLL_WAIT_S). Room joins use the poll id like an SSE id (activeSSEId).
const POLL_WAIT_S = 25; // below typical proxy timeouts, like the note sync's long polling
let pollRunning = false;
let pollAbort: AbortController | null = null;
function dispatchRealtimeMessage(msg: WSMessage) {
wsListeners.forEach((fn) => {
try {
fn(msg);
} catch (e) {
console.error('Realtime listener error', e);
}
});
window.dispatchEvent(new CustomEvent('mindsift:ws', { detail: msg }));
}
async function connectPoll(): Promise<void> {
if (pollRunning || manualDisconnect || !getToken()) return;
pollRunning = true;
let pollId: string | null = null;
let failures = 0;
try {
while (!manualDisconnect && getToken()) {
const ctrl = new AbortController();
pollAbort = ctrl;
try {
const fresh = !pollId;
if (!pollId) {
const opened = await request<{ pollId?: string }>('/api/v1/events/poll', {
method: 'POST',
body: JSON.stringify({ rooms: Array.from(globalSubscribedRooms) }),
});
if (!opened?.pollId) throw new Error('no poll id');
pollId = opened.pollId;
activeSSEId = pollId;
notifyRealtimeStatusChange(true, 'poll');
}
const started = Date.now();
const res = await fetch(`/api/v1/events/poll/${encodeURIComponent(pollId)}?wait=${POLL_WAIT_S}`, {
headers: { Authorization: `Bearer ${getToken()}` },
cache: 'no-store',
signal: ctrl.signal,
});
if (res.status === 404 && !fresh) {
// Expired (e.g. device asleep, server restart): open a new one, rooms go along
pollId = null;
continue;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const events = ((await res.json()) as { events?: WSMessage[] }).events || [];
failures = 0;
events.forEach(dispatchRealtimeMessage);
// An empty answer long before the wait ran out (proxy answering on its own): no tight loop
if (events.length === 0 && Date.now() - started < 1000) await new Promise((r) => setTimeout(r, 1000));
} catch {
if (ctrl.signal.aborted || manualDisconnect) break;
pollId = null;
if (activeTransport === 'poll') {
activeSSEId = null;
notifyRealtimeStatusChange(false, 'none');
}
failures++;
await new Promise((r) => setTimeout(r, Math.min(30000, 2000 * failures)));
}
}
} finally {
pollRunning = false;
pollAbort = null;
}
}
let wsTicketPending = false;
export async function connectWebSocket(): Promise<void> {
@@ -1845,6 +1919,7 @@ if (typeof window !== 'undefined') {
}
export function disconnectWebSocket(): void {
pollAbort?.abort();
clearInterval(wsHeartbeatTimer);
clearTimeout(sseFirstEventWatchdog);
manualDisconnect = true;