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) => {
35 requests.get(requestID) ??
36 awaiter<RequestAsyncResult>({
37 onResolve: () => requests.delete(requestID),
38 onReject: () => requests.delete(requestID),
41 requests.set(requestID, asyncResult);
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 }> =>
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;
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) {
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));
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 }));
106 return maybePromise(() => tracker.push(requestID));
112 return maybePromise(() => tracker.push(requestID));
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') {
124 data: 'payload' in action ? action.payload : undefined,
134 export const requestMiddleware = requestMiddlewareFactory();