Merge branch 'fix/sentry-issue' into 'main'
[ProtonMail-WebClient.git] / packages / pass / store / request / middleware.ts
blob2101369c28d324661bdd3725f2bcd4ab6ada7560
1 import type { Action } from '@reduxjs/toolkit';
2 import { type Middleware, isAction } from 'redux';
4 import { selectRequest } from '@proton/pass/store/selectors';
5 import type { Awaiter } from '@proton/pass/utils/fp/promises';
6 import { awaiter } from '@proton/pass/utils/fp/promises';
7 import { getEpoch } from '@proton/pass/utils/time/epoch';
8 import noop from '@proton/utils/noop';
10 import type { RequestAsyncResult, RequestState, RequestType, WithRequest } from './types';
11 import { isActionWithRequest } from './utils';
13 export interface RequestTracker {
14     requests: Map<string, Awaiter<RequestAsyncResult>>;
15     push: (requestID: string) => Awaiter<RequestAsyncResult>;
18 export type RequestAsyncAccept = (action: WithRequest<Action, RequestType, unknown>) => boolean;
20 type RequestMiddlewareOptions = {
21     acceptAsync?: RequestAsyncAccept;
22     tracker?: RequestTracker;
25 export const requestTrackerFactory = (): RequestTracker => {
26     /** Map storing promise-like awaiters indexed by requestID.
27      * Used to track pending requests and resolve/reject them when
28      * the corresponding success/failure actions are dispatched */
29     const requests = new Map<string, Awaiter<RequestAsyncResult>>();
31     /** Creates a new awaiter for a given `requestID`
32      * and stores it in the results map */
33     const createAsyncResult = (requestID: string) => {
34         const asyncResult =
35             requests.get(requestID) ??
36             awaiter<RequestAsyncResult>({
37                 onResolve: () => requests.delete(requestID),
38                 onReject: () => requests.delete(requestID),
39             });
41         requests.set(requestID, asyncResult);
42         return asyncResult;
43     };
45     return { requests, push: createAsyncResult };
48 /** Redux middleware for managing async request lifecycles and
49  * caching. Handles request deduplication, TTL-based caching,
50  * and promise management. Provides a `thunk-like` API for UI
51  * requests while supporting saga architecture.  */
52 export const requestMiddlewareFactory =
53     (options?: RequestMiddlewareOptions): Middleware<{}, { request: RequestState }> =>
54     ({ getState }) => {
55         const tracker = options?.tracker ?? requestTrackerFactory();
57         const acceptAsync: RequestAsyncAccept = (action) => {
58             if (!action.meta.request.async) return false;
59             return options?.acceptAsync?.(action) ?? true;
60         };
62         return (next) => {
63             return (action: unknown) => {
64                 if (isAction(action)) {
65                     if (!isActionWithRequest(action)) return next(action);
67                     const { request } = action.meta;
68                     const { status, id: requestID } = request;
69                     const pending = tracker.requests.get(requestID);
71                     /** Handles the start of a request :
72                      * 1. Revalidation: By-passes cached result always
73                      * 2. Pending request: Returns existing promise to prevent concurrent requests
74                      * 3. Cached result: Skips request if within maxAge, otherwise processes normally */
75                     if (status === 'start') {
76                         /** Returns promises only for UI-dispatched actions (`async: true`)
77                          * to match the `redux-thunk` pattern. Otherwise, returns `noop` to
78                          * avoid unnecessary promise tracking. This aligns with our saga
79                          * architecture while maintaining thunk-like API for UI requests. */
80                         const maybePromise = (result: () => Promise<RequestAsyncResult>) =>
81                             (acceptAsync(action) ? result : noop)();
83                         const pendingRequest = selectRequest(requestID)(getState());
85                         switch (pendingRequest?.status) {
86                             case 'start': {
87                                 /** If a request with this ID is already pending:
88                                  * - return the existing promise to avoid duplicates
89                                  * - else create a new async result without processing
90                                  * the action to prevent concurrent executions */
91                                 return maybePromise(() => tracker.push(requestID));
92                             }
94                             case 'success': {
95                                 /** For cached requests:
96                                  * - check if the cached result is still valid based on `maxAge`
97                                  * - if valid, return cached data without processing action
98                                  * - if expired, process normally with new async result */
99                                 const now = getEpoch();
100                                 const { maxAge, requestedAt, data } = pendingRequest;
101                                 const cached = !request.revalidate && maxAge && requestedAt + maxAge > now;
103                                 if (cached) return maybePromise(async () => ({ type: 'success', data }));
104                                 else {
105                                     next(action);
106                                     return maybePromise(() => tracker.push(requestID));
107                                 }
108                             }
110                             default: {
111                                 next(action);
112                                 return maybePromise(() => tracker.push(requestID));
113                             }
114                         }
115                     }
117                     /** Handle request completion by resolving/rejecting the tracked promise:
118                      * - For success: resolves promise with action payload
119                      * - For failure: rejects promise with action payload
120                      * - Cleans up by removing the tracked promise from results map */
121                     if (status === 'success' || status === 'failure') {
122                         pending?.resolve({
123                             type: status,
124                             data: 'payload' in action ? action.payload : undefined,
125                         });
126                     }
128                     return next(action);
129                 }
130             };
131         };
132     };
134 export const requestMiddleware = requestMiddlewareFactory();