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:
1 parent
c19d8fd03e
commit
2b7356d043
16 files changed
+369
-20
No files matched your search
+77
-2
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user