feat: add realtime
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { io } from 'socket.io-client';
|
||||
import { API_URL, getAccessToken, refreshAccessToken } from '@/lib/api/client';
|
||||
|
||||
// Server topics mapped to the dashboard queries that show that data
|
||||
const TOPIC_QUERIES: Record<string, string[][]> = {
|
||||
tabs: [['tabs'], ['reminder-candidates']],
|
||||
commands: [['commands']],
|
||||
'planned-tabs': [['planned-tabs'], ['reminder-candidates']],
|
||||
device: [['device']],
|
||||
reminders: [['reminder-candidates'], ['reminder-history']],
|
||||
users: [['users']],
|
||||
};
|
||||
|
||||
const RealtimeContext = createContext({ connected: false });
|
||||
|
||||
export function RealtimeProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = io(`${new URL(API_URL).origin}/realtime`, {
|
||||
// Read on every (re)connect so a refreshed token is used
|
||||
auth: (cb) => cb({ token: getAccessToken() }),
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
let hasConnected = false;
|
||||
let refreshing = false;
|
||||
let rejections = 0;
|
||||
|
||||
socket.on('connect', () => {
|
||||
setConnected(true);
|
||||
rejections = 0;
|
||||
// Changes may have been missed while disconnected
|
||||
if (hasConnected) queryClient.invalidateQueries();
|
||||
hasConnected = true;
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => setConnected(false));
|
||||
|
||||
socket.on('invalidate', ({ topics }: { topics: string[] }) => {
|
||||
for (const topic of topics) {
|
||||
for (const queryKey of TOPIC_QUERIES[topic] ?? []) {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The access token expired: refresh it and reconnect (rejected handshakes are not retried
|
||||
// automatically). If refreshing fails, the next API call sends the user to the login page.
|
||||
socket.on('connect_error', async (error) => {
|
||||
if (error.message !== 'unauthorized' || refreshing || ++rejections > 3) return;
|
||||
refreshing = true;
|
||||
const token = await refreshAccessToken();
|
||||
refreshing = false;
|
||||
if (token) socket.connect();
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [queryClient]);
|
||||
|
||||
return <RealtimeContext.Provider value={{ connected }}>{children}</RealtimeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useRealtime() {
|
||||
return useContext(RealtimeContext);
|
||||
}
|
||||
|
||||
/** Polling interval to use only while live updates are unavailable. */
|
||||
export function usePollingFallback(intervalMs: number): number | false {
|
||||
const { connected } = useRealtime();
|
||||
return connected ? false : intervalMs;
|
||||
}
|
||||
Reference in New Issue
Block a user