mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: reconnect live updates after network blips, token refresh, account switch and app resume
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user