fix: reconnect live updates after network blips, token refresh, account switch and app resume

This commit is contained in:
Linus Rath
2026-08-29 17:57:33 +02:00
parent 032732cea1
commit edc26ce1ca
3 changed files with 499 additions and 127 deletions
+99 -18
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; import { ActivityIndicator, AppState, StyleSheet, Text, View } from 'react-native';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { useColorScheme } from 'react-native'; import { useColorScheme } from 'react-native';
import { NavigationContainer, createNavigationContainerRef } from '@react-navigation/native'; import { NavigationContainer, createNavigationContainerRef } from '@react-navigation/native';
@@ -7,7 +7,9 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack'; import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack';
import { Mail, Calendar, BookUser, HardDrive, Settings } from 'lucide-react-native'; import { Mail, Calendar, BookUser, HardDrive, Settings } from 'lucide-react-native';
import { startPushUpdates } from './src/api/push'; import { startLiveUpdates, type LiveUpdatesHandle } from './src/api/push-stream';
import { jmapClient } from './src/api/jmap-client';
import type { StateChange } from './src/api/types';
import { import {
addMessageListener, addMessageListener,
addNotificationTapListener, addNotificationTapListener,
@@ -388,45 +390,124 @@ export default function App() {
}; };
}, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]); }, [client, isAuthenticated, emailNotificationsEnabled, activeAccountId]);
// Live updates (SSE with polling fallback), re-armed on every account
// switch and every re-established session — the singleton `client` object
// never changes identity, so it cannot be the dependency on its own.
// Backgrounding closes the stream (no socket + server pings while the app
// is asleep); foregrounding reconnects it and refreshes what the user is
// looking at, since events that happened in between are gone for good.
React.useEffect(() => { React.useEffect(() => {
if (!isAuthenticated || !client) { if (!isAuthenticated || !client || !haveLiveSession) {
return; return;
} }
let mounted = true; let mounted = true;
let cleanup: (() => void) | null = null; let handle: LiveUpdatesHandle | null = null;
let appActive = AppState.currentState !== 'background' && AppState.currentState !== 'inactive';
void (async () => { const onStateChange = async (change: StateChange) => {
try {
const stopPushUpdates = await startPushUpdates(client, {
onStateChange: async (change) => {
await Promise.all([ await Promise.all([
useEmailStore.getState().handleStateChange(change), useEmailStore.getState().handleStateChange(change),
useContactsStore.getState().handleStateChange(change), useContactsStore.getState().handleStateChange(change),
useCalendarStore.getState().handleStateChange(change), useCalendarStore.getState().handleStateChange(change),
]); ]);
}, };
onError: (error) => {
console.warn(error.message);
},
});
const start = async () => {
try {
const next = await startLiveUpdates({
onStateChange: (change) => { void onStateChange(change); },
onError: (error) => { console.warn('[push]', error.message); },
onFallback: (reason) => { console.warn('[push] falling back to polling:', reason); },
isActive: () => appActive,
});
if (!mounted) { if (!mounted) {
stopPushUpdates(); next.close();
return; return;
} }
handle = next;
cleanup = stopPushUpdates;
} catch (error) { } catch (error) {
console.warn(error instanceof Error ? error.message : 'Failed to start JMAP push updates'); console.warn(error instanceof Error ? error.message : 'Failed to start JMAP push updates');
} }
};
const refreshAfterResume = () => {
const email = useEmailStore.getState();
void email.fetchMailboxes();
if (email.currentMailboxId) void email.refreshEmails();
void useOutboxStore.getState().flush();
};
void start();
const subscription = AppState.addEventListener('change', (state) => {
const nowActive = state === 'active';
if (nowActive === appActive) return;
appActive = nowActive;
if (!nowActive) {
handle?.close();
handle = null;
return;
}
// Coming back: verify the session is still alive before trusting the
// stream, then reconnect and catch up on what was missed.
void (async () => {
const alive = await jmapClient.ping().catch(() => false);
if (!mounted) return;
if (!alive && useNetworkStore.getState().online) {
const ok = await useAuthStore.getState().retrySession().catch(() => false);
if (!ok || !mounted) return;
}
if (!handle) await start();
else handle.reconnect();
refreshAfterResume();
})(); })();
});
// Reconnect when the network comes back while foregrounded.
const unsubscribeNetwork = useNetworkStore.subscribe((state, prev) => {
if (state.online && !prev.online && appActive) {
if (handle) handle.reconnect();
else void start();
}
});
return () => { return () => {
mounted = false; mounted = false;
cleanup?.(); subscription.remove();
unsubscribeNetwork();
handle?.close();
handle = null;
}; };
}, [client, isAuthenticated]); }, [client, isAuthenticated, haveLiveSession, activeAccountId]);
// Foreground keep-alive: a `Core/echo` every 30 s tells us when the server
// is unreachable even though the device is online (NetInfo cannot), and
// drives the per-account connection dot.
React.useEffect(() => {
if (!isAuthenticated || !haveLiveSession || !activeAccountId) return;
let cancelled = false;
let failures = 0;
const timer = setInterval(() => {
if (AppState.currentState !== 'active') return;
if (!useNetworkStore.getState().online) return;
void jmapClient.ping().then((ok) => {
if (cancelled) return;
failures = ok ? 0 : failures + 1;
const account = useAccountStore.getState().getAccountById(activeAccountId);
if (!account) return;
// One missed echo can be a blip; two in a row is a lost connection.
const connected = failures < 2;
if (account.isConnected !== connected) {
useAccountStore.getState().updateAccount(activeAccountId, { isConnected: connected });
}
}).catch(() => undefined);
}, 30_000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [isAuthenticated, haveLiveSession, activeAccountId]);
// Skip the "Restoring session" flash for returning users: if we already // Skip the "Restoring session" flash for returning users: if we already
// have a persisted active account, render the main UI immediately with // have a persisted active account, render the main UI immediately with
+374
View File
@@ -0,0 +1,374 @@
// Live updates from the JMAP EventSource (SSE), with the lifecycle the plain
// `react-native-sse` EventSource lacks:
//
// - reconnect with back-off after a network error (the library only re-polls
// when the XHR reaches DONE with a status; a dropped socket - iOS
// suspension, Wi-Fi → cellular - fires `error` with status 0 and then
// nothing ever happens again);
// - a fresh Authorization header on every (re)connect, and an immediate
// reconnect when the OAuth token rotates (the library reuses the headers it
// was constructed with, so after a refresh every retry sent the stale
// bearer: 401 → error → retry every 5 s forever);
// - a 90 s ping watchdog and a periodic recycle so the accumulated
// `responseText` cannot grow without bound;
// - fallback to polling when the stream is refused (proxy without SSE, TLS
// client certificate that XHR cannot present, repeated failures).
import { jmapClient, AuthenticationError } from './jmap-client';
import { CAPABILITIES } from './types';
import type { StateChange } from './types';
import { getClientCertAlias } from '../lib/client-cert';
export type StateChangeHandler = (change: StateChange) => void;
// Server pings every 30 s; consider the stream dead after three missed ones.
const PING_SECONDS = 30;
const PING_TIMEOUT_MS = 90_000;
// Recycle a healthy stream periodically to bound the XHR buffer.
const RECYCLE_MS = 30 * 60_000;
const RECONNECT_DELAYS_MS = [2_000, 5_000, 10_000, 20_000, 40_000, 60_000];
// After this many consecutive failed connects the stream is given up on and
// polling takes over for the rest of the session.
const MAX_CONSECUTIVE_FAILURES = 6;
export const POLL_INTERVAL_MS = 5_000;
export const SLOW_POLL_INTERVAL_MS = 20_000;
interface SseEvent {
type?: string;
data?: string;
message?: string;
xhrStatus?: number;
}
interface EventSourceLike {
addEventListener: (type: string, handler: (event: SseEvent) => void) => void;
removeEventListener: (type: string, handler: (event: SseEvent) => void) => void;
close: () => void;
}
type EventSourceCtor = new (
url: string,
options: { headers: Record<string, string>; pollingInterval?: number; timeoutBeforeConnection?: number },
) => EventSourceLike;
function eventSourceImpl(): EventSourceCtor {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('react-native-sse').default as EventSourceCtor;
}
export interface LiveUpdatesOptions {
onStateChange: StateChangeHandler;
onError?: (error: Error) => void;
/** Called when the stream is abandoned for polling. */
onFallback?: (reason: string) => void;
/** Returns false while the app is backgrounded; polling pauses. */
isActive?: () => boolean;
}
export interface LiveUpdatesHandle {
/** Tear everything down (stream, timers, polling). */
close: () => void;
/** Drop the current stream and connect again (foreground resume). */
reconnect: () => void;
/** 'sse' | 'polling' | 'closed' */
readonly mode: 'sse' | 'polling' | 'closed';
}
function buildEventSourceUrl(template: string): string {
return template
.replace('{types}', '*')
.replace('{closeafter}', 'no')
.replace('{ping}', String(PING_SECONDS));
}
/**
* Polling fallback: `Mailbox/get` + `Email/get` with empty id lists return
* the current state tokens; a change in either is reported as a StateChange
* for the primary account. One request in flight at a time, paused while
* backgrounded.
*/
export function startPolling(
onStateChange: StateChangeHandler,
interval = POLL_INTERVAL_MS,
isActive: () => boolean = () => true,
): () => void {
let accountId: string;
try {
accountId = jmapClient.accountId;
} catch {
return () => undefined;
}
const stateCache: Record<string, string> = {};
let inFlight = false;
let stopped = false;
const tick = async () => {
if (stopped || inFlight || !isActive()) return;
if ('isConnected' in jmapClient && !jmapClient.isConnected) return;
inFlight = true;
try {
const res = await jmapClient.request(
[
['Mailbox/get', { accountId, ids: [] }, 'm'],
['Email/get', { accountId, ids: [] }, 'e'],
],
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
);
const changed: Record<string, string> = {};
for (const [method, result] of res.methodResponses) {
if (method === 'error') continue;
const type = method.replace('/get', '');
const newState = (result as { state?: string }).state;
if (newState && stateCache[type] && stateCache[type] !== newState) {
changed[type] = newState;
}
if (newState) stateCache[type] = newState;
}
if (Object.keys(changed).length > 0) {
onStateChange({ '@type': 'StateChange', changed: { [accountId]: changed } });
}
} catch {
// Silently retry on next interval
} finally {
inFlight = false;
}
};
const timer = setInterval(() => { void tick(); }, interval);
return () => {
stopped = true;
clearInterval(timer);
};
}
/**
* Start live updates: SSE when the session advertises an eventSourceUrl and
* no client certificate is in play, polling otherwise. Resolves to a handle
* that can be closed or told to reconnect.
*/
export async function startLiveUpdates(opts: LiveUpdatesOptions): Promise<LiveUpdatesHandle> {
const isActive = opts.isActive ?? (() => true);
const safeOnStateChange: StateChangeHandler = (change) => {
try {
opts.onStateChange(change);
} catch (err) {
opts.onError?.(err instanceof Error ? err : new Error(String(err)));
}
};
let mode: 'sse' | 'polling' | 'closed' = 'closed';
let stopPolling: (() => void) | null = null;
let es: EventSourceLike | null = null;
let esHandlers: Array<[string, (event: SseEvent) => void]> = [];
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let watchdog: ReturnType<typeof setTimeout> | null = null;
let recycleTimer: ReturnType<typeof setTimeout> | null = null;
let consecutiveFailures = 0;
let closed = false;
let unsubscribeTokenRefresh: (() => void) | null = null;
const session = jmapClient.currentSession;
const template = session?.eventSourceUrl;
const certAlias = await getClientCertAlias().catch(() => null);
const clearTimers = () => {
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
if (watchdog) { clearTimeout(watchdog); watchdog = null; }
if (recycleTimer) { clearTimeout(recycleTimer); recycleTimer = null; }
};
const dropStream = () => {
if (es) {
for (const [type, handler] of esHandlers) {
try { es.removeEventListener(type, handler); } catch { /* ignore */ }
}
esHandlers = [];
try { es.close(); } catch { /* ignore */ }
es = null;
}
if (watchdog) { clearTimeout(watchdog); watchdog = null; }
if (recycleTimer) { clearTimeout(recycleTimer); recycleTimer = null; }
};
const fallbackToPolling = (reason: string) => {
if (closed) return;
dropStream();
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
if (mode !== 'polling') {
mode = 'polling';
opts.onFallback?.(reason);
stopPolling = startPolling(safeOnStateChange, SLOW_POLL_INTERVAL_MS, isActive);
}
};
const scheduleReconnect = () => {
if (closed || reconnectTimer) return;
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
fallbackToPolling('stream failed repeatedly');
return;
}
const delay = RECONNECT_DELAYS_MS[Math.min(consecutiveFailures, RECONNECT_DELAYS_MS.length - 1)];
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
void connect();
}, delay);
};
const armWatchdog = () => {
if (watchdog) clearTimeout(watchdog);
watchdog = setTimeout(() => {
// No ping for 90 s: the socket is dead even though the XHR never said so.
dropStream();
consecutiveFailures += 1;
scheduleReconnect();
}, PING_TIMEOUT_MS);
};
const connect = async () => {
if (closed || !template) return;
if (!isActive()) return; // resumed later by reconnect()
dropStream();
try {
await jmapClient.ensureFreshToken();
} catch {
// the 401 path below handles a stale token
}
let authHeader: string;
try {
authHeader = jmapClient.authHeader;
} catch {
return;
}
const url = buildEventSourceUrl(template);
let source: EventSourceLike;
try {
const Impl = eventSourceImpl();
// pollingInterval 0 disables the library's own re-poll on DONE; every
// reconnect goes through scheduleReconnect so headers stay fresh.
source = new Impl(url, {
headers: { Authorization: authHeader },
pollingInterval: 0,
timeoutBeforeConnection: 0,
});
} catch (err) {
opts.onError?.(err instanceof Error ? err : new Error(String(err)));
consecutiveFailures += 1;
scheduleReconnect();
return;
}
es = source;
mode = 'sse';
const on = (type: string, handler: (event: SseEvent) => void) => {
source.addEventListener(type, handler);
esHandlers.push([type, handler]);
};
on('open', () => {
consecutiveFailures = 0;
armWatchdog();
if (recycleTimer) clearTimeout(recycleTimer);
recycleTimer = setTimeout(() => {
if (closed || es !== source) return;
dropStream();
void connect();
}, RECYCLE_MS);
});
on('ping', () => armWatchdog());
on('state', (event) => {
armWatchdog();
try {
const data: StateChange = JSON.parse(typeof event === 'string' ? event : event.data ?? '');
safeOnStateChange(data);
} catch {
// Ignore malformed events
}
});
on('close', () => {
if (closed || es !== source) return;
dropStream();
scheduleReconnect();
});
on('error', (event) => {
if (closed || es !== source) return;
dropStream();
const status = event?.xhrStatus ?? 0;
if (status === 401) {
// Stale bearer: refresh and come straight back with the new header.
void jmapClient.forceRefreshToken().then(
(refreshed) => {
if (closed) return;
if (refreshed) {
consecutiveFailures = 0;
void connect();
} else {
consecutiveFailures += 1;
opts.onError?.(new AuthenticationError('Live updates rejected: session expired'));
scheduleReconnect();
}
},
() => {
consecutiveFailures += 1;
scheduleReconnect();
},
);
return;
}
if (status >= 400) {
// The endpoint answered but refused the stream (proxy without SSE,
// rate limit, server error): don't hammer it.
consecutiveFailures += 2;
opts.onError?.(new Error(`Live updates stream refused: ${status}`));
scheduleReconnect();
return;
}
// status 0: socket dropped / no network. Back off and retry.
consecutiveFailures += 1;
scheduleReconnect();
});
};
if (!template || certAlias) {
mode = 'polling';
opts.onFallback?.(certAlias ? 'client certificate in use' : 'server has no eventSourceUrl');
stopPolling = startPolling(safeOnStateChange, POLL_INTERVAL_MS, isActive);
} else {
unsubscribeTokenRefresh = jmapClient.onTokenRefresh(() => {
if (closed || mode !== 'sse') return;
// New bearer: the open stream still carries the old one for its
// lifetime, which is fine, but every reconnect from now on must use
// the new header - recycle now so a pending stale retry can't loop.
dropStream();
consecutiveFailures = 0;
void connect();
});
await connect();
}
return {
get mode() {
return mode;
},
close: () => {
closed = true;
mode = 'closed';
clearTimers();
dropStream();
unsubscribeTokenRefresh?.();
unsubscribeTokenRefresh = null;
stopPolling?.();
stopPolling = null;
},
reconnect: () => {
if (closed) return;
if (mode === 'polling') return; // polling keeps running on its own
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
consecutiveFailures = 0;
dropStream();
void connect();
},
};
}
+21 -104
View File
@@ -117,123 +117,40 @@ export async function destroyPushSubscription(id: string): Promise<void> {
); );
} }
/** // ─── Live updates ────────────────────────────────────────
* Connect to the JMAP EventSource (SSE) endpoint for real-time push updates. // The EventSource lifecycle (reconnect with back-off, fresh bearer on every
* Returns a cleanup function to close the connection. // connect, ping watchdog, polling fallback) lives in ./push-stream. These
*/ // re-exports keep the historical entry points.
export function connectEventSource(
onStateChange: StateChangeHandler,
): (() => void) | null {
const session = jmapClient.currentSession;
if (!session?.eventSourceUrl) return null;
const url = session.eventSourceUrl export {
.replace('{types}', '*') startLiveUpdates,
.replace('{closeafter}', 'no') startPolling,
.replace('{ping}', '30'); type LiveUpdatesHandle,
type LiveUpdatesOptions,
// Use native EventSource or react-native-sse } from './push-stream';
const EventSourceImpl = typeof EventSource !== 'undefined'
? EventSource
: require('react-native-sse').default;
const es = new EventSourceImpl(url, {
headers: { Authorization: jmapClient.authHeader },
});
const handler = (event: any) => {
try {
const data: StateChange = JSON.parse(
typeof event === 'string' ? event : event.data,
);
onStateChange(data);
} catch {
// Ignore malformed events
}
};
es.addEventListener('state', handler);
return () => {
es.removeEventListener('state', handler);
es.close();
};
}
/**
* Polling fallback when SSE is not available.
* Returns a cleanup function to stop polling.
*/
export function startPolling(
onStateChange: StateChangeHandler,
interval = 5000,
): () => void {
const accountId = jmapClient.accountId;
const stateCache: Record<string, string> = {};
const timer = setInterval(async () => {
try {
const res = await jmapClient.request(
[
['Mailbox/get', { accountId, ids: [] }, 'm'],
['Email/get', { accountId, ids: [] }, 'e'],
],
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
);
const changed: Record<string, string> = {};
for (const [method, result] of res.methodResponses) {
const type = method.replace('/get', '');
const newState = result.state;
if (newState && stateCache[type] && stateCache[type] !== newState) {
changed[type] = newState;
}
if (newState) stateCache[type] = newState;
}
if (Object.keys(changed).length > 0) {
onStateChange({
'@type': 'StateChange',
changed: { [accountId]: changed },
});
}
} catch {
// Silently retry on next interval
}
}, interval);
return () => clearInterval(timer);
}
export interface StartPushOptions { export interface StartPushOptions {
onStateChange: StateChangeHandler; onStateChange: StateChangeHandler;
onError?: (error: Error) => void; onError?: (error: Error) => void;
onFallback?: (reason: string) => void;
isActive?: () => boolean;
} }
/** /**
* Start real-time updates, preferring SSE with polling fallback. The `_client` * Start real-time updates, preferring SSE with polling fallback. Resolves to
* argument is unused today (the underlying event source uses the singleton * a cleanup function; prefer `startLiveUpdates` when the handle's
* `jmapClient`) but is part of the call signature so the host App can pass * `reconnect()` is needed (foreground resume).
* the active client reference and trigger React re-runs on account switch.
*/ */
export function startPushUpdates( export async function startPushUpdates(
_client: unknown, _client: unknown,
opts: StartPushOptions, opts: StartPushOptions,
): () => void { ): Promise<() => void> {
const onError = opts.onError; const { startLiveUpdates: start } = await import('./push-stream');
const safeOnStateChange: StateChangeHandler = (change) => {
try { try {
opts.onStateChange(change); const handle = await start(opts);
return () => handle.close();
} catch (err) { } catch (err) {
if (onError) onError(err instanceof Error ? err : new Error(String(err))); opts.onError?.(err instanceof Error ? err : new Error(String(err)));
}
};
try {
const cleanup = connectEventSource(safeOnStateChange);
if (cleanup) return cleanup;
return startPolling(safeOnStateChange);
} catch (err) {
if (onError) onError(err instanceof Error ? err : new Error(String(err)));
return () => undefined; return () => undefined;
} }
} }