Merge branch 'IDTEAM-1.26.0' into 'main'
[ProtonMail-WebClient.git] / packages / pass / lib / events / manager.ts
blob210383ba89916bfc11a0485b11c1ea569c77cf56
1 import type { Maybe } from '@proton/pass/types';
2 import type { Api } from '@proton/pass/types/api';
3 import { logger } from '@proton/pass/utils/logger';
4 import { FIBONACCI_LIST } from '@proton/shared/lib/constants';
5 import createListeners from '@proton/shared/lib/helpers/listeners';
6 import { onceWithQueue } from '@proton/shared/lib/helpers/onceWithQueue';
8 import { ACTIVE_POLLING_TIMEOUT } from './constants';
10 export type EventManagerEvent<T extends {}> = T | { error: unknown };
11 export type EventCursor = { EventID: string; More: boolean };
13 export const NOOP_EVENT = '*';
15 export type EventManagerConfig<T extends {}> = {
16     api: Api /* Function to call the API */;
17     interval?: number /* Maximum interval time to wait between each call */;
18     initialEventID: string;
19     query: (eventID: string) => {} /* Event polling endpoint override */;
20     getCursor: (event: T) => EventCursor;
21     getLatestEventID?: () => Promise<string> | string;
24 export type EventManager<T extends {}> = {
25     state: EventManagerState;
26     setEventID: (eventID: string) => void;
27     getEventID: () => Maybe<string>;
28     start: () => void;
29     stop: () => void;
30     call: () => Promise<void>;
31     reset: () => void;
32     setInterval: (interval: number) => void;
33     subscribe: (listener: (event: EventManagerEvent<T>) => void) => () => void;
36 type EventManagerState = {
37     interval: number;
38     retryIndex: number;
39     lastEventID?: string;
40     timeoutHandle?: any;
41     abortController?: AbortController;
44 export const eventManager = <T extends {}>({
45     api,
46     interval = ACTIVE_POLLING_TIMEOUT,
47     initialEventID,
48     query,
49     getCursor,
50     getLatestEventID,
51 }: EventManagerConfig<T>): EventManager<T> => {
52     const listeners = createListeners<[EventManagerEvent<T>]>();
54     const state: EventManagerState = { interval, retryIndex: 0, lastEventID: initialEventID };
56     const setInterval = (nextInterval: number) => (state.interval = nextInterval);
57     const setEventID = (eventID: string) => (state.lastEventID = eventID);
58     const getEventID = () => (state.lastEventID ? state.lastEventID : undefined);
59     const setRetryIndex = (index: number) => (state.retryIndex = index);
60     const getRetryIndex = () => state.retryIndex;
62     /* Increase the retry index when the call fails to not spam */
63     const increaseRetryIndex = () => {
64         const index = getRetryIndex();
65         if (index < FIBONACCI_LIST.length - 1) setRetryIndex(index + 1);
66     };
68     /* Start the event manager, does nothing if it is already started */
69     const start = (callFn: () => Promise<void>) => {
70         if (!state.timeoutHandle) {
71             const ms = state.interval * FIBONACCI_LIST[state.retryIndex];
72             state.timeoutHandle = setTimeout(callFn, ms);
73         }
74     };
76     /* Stop the event manager, does nothing if it's already stopped */
77     const stop = () => {
78         if (state.abortController) {
79             state.abortController.abort();
80             delete state.abortController;
81         }
83         if (state.timeoutHandle) {
84             clearTimeout(state.timeoutHandle);
85             delete state.timeoutHandle;
86         }
87     };
89     /* Stop the event manager and reset its state */
90     const reset = () => {
91         stop();
93         state.retryIndex = 0;
94         state.interval = interval;
95         delete state.abortController;
96         delete state.lastEventID;
97         delete state.timeoutHandle;
99         listeners.clear();
100     };
102     /* Call the event manager. Either does it immediately, or queues
103      * the call until after the current call has finished */
104     const call = onceWithQueue(async () => {
105         try {
106             stop();
108             const abortController = new AbortController();
109             state.abortController = abortController;
111             while (true) {
112                 const eventID = getEventID() ?? (await getLatestEventID?.());
114                 if (!eventID) {
115                     logger.warn('No valid `EventID` provided');
116                     return;
117                 }
119                 const result = await api<T>({ ...query(eventID), signal: abortController.signal, silence: true });
120                 if (!result) return;
122                 await Promise.all(listeners.notify(result));
123                 const { More, EventID: nextEventID } = getCursor(result);
125                 setEventID(nextEventID);
126                 setRetryIndex(0);
128                 if (!More) break;
129             }
131             delete state.abortController;
132             start(call);
133         } catch (error: any) {
134             /* ⚠️ if the request failed due to a locked or inactive session :
135              * do not restart the event-manager. For any other type of error,
136              * we can safely increase the retry index and retry.. */
137             const { appVersionBad, sessionInactive, sessionLocked } = api.getState();
138             if (error.name === 'AbortError' || appVersionBad || sessionInactive || sessionLocked) return;
140             delete state.abortController;
141             increaseRetryIndex();
142             start(call);
144             listeners.notify({ error });
145             throw error;
146         }
147     });
149     return {
150         setEventID,
151         getEventID,
152         setInterval,
153         start: () => start(call),
154         stop,
155         call,
156         reset,
157         subscribe: listeners.subscribe,
158         get state() {
159             return state;
160         },
161     };