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

@@ -137,4 +137,36 @@ describe('realtime fallback', () => {
expect(FakeEventSource.instances).toHaveLength(1); // no reconnect loop: note sync uses long polling
api.disconnectWebSocket();
});
it('behind such a proxy, events come by long polling', async () => {
sessionStorage.setItem('mindsift_ws_blocked', 'true');
sessionStorage.setItem('mindsift_sse_buffered', 'true');
let polls = 0;
vi.stubGlobal(
'fetch',
vi.fn(async (url: string, init?: RequestInit) => {
if (url === '/api/v1/events/poll') return new Response('{"pollId":"poll_1"}', { status: 200 });
if (url.startsWith('/api/v1/events/poll/poll_1')) {
polls++;
if (polls === 1) return new Response('{"events":[{"event":"TASK_UPDATED","payload":{"id":"t1"}}]}', { status: 200 });
// Later polls wait like the server does, until the client gives up on them
return new Promise<Response>((_, reject) => init?.signal?.addEventListener('abort', () => reject(new Error('aborted'))));
}
return new Response('{}', { status: 200 });
})
);
const api = await loadApi();
const seen: string[] = [];
api.onWebSocketMessage((m) => seen.push(m.event));
await api.connectWebSocket();
await vi.advanceTimersByTimeAsync(100);
expect(FakeEventSource.instances).toHaveLength(0);
expect(api.isRealtimeConnected()).toBe(true);
expect(api.getRealtimeTransport()).toBe('poll');
expect(seen).toEqual(['TASK_UPDATED']);
expect(polls).toBe(2); // the next poll is waiting
api.disconnectWebSocket();
expect(api.isRealtimeConnected()).toBe(false);
});
});