@terreno/rtk 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,335 @@
1
+ // This file is the basis for openApiSdk.ts. See openapi-config.ts for configuration that is
2
+ // combined with this to generate the SDK.
3
+ import AsyncStorage from "@react-native-async-storage/async-storage";
4
+ import {
5
+ type BaseQueryApi,
6
+ createApi,
7
+ type FetchArgs,
8
+ fetchBaseQuery,
9
+ retry,
10
+ } from "@reduxjs/toolkit/query/react";
11
+ import {Mutex} from "async-mutex";
12
+ import axios from "axios";
13
+ import axiosRetry from "axios-retry";
14
+ import Constants from "expo-constants";
15
+ import * as SecureStore from "expo-secure-store";
16
+ import {jwtDecode} from "jwt-decode";
17
+ import {DateTime} from "luxon";
18
+ import qs from "qs";
19
+ import {generateProfileEndpoints, getAuthToken} from "./authSlice";
20
+ import {AUTH_DEBUG, baseUrl, LOGOUT_ACTION_TYPE, TOKEN_REFRESHED_SUCCESS} from "./constants";
21
+ import {IsWeb} from "./platform";
22
+
23
+ const log = AUTH_DEBUG ? (s: string): void => console.debug(`[auth] ${s}`) : (): void => {};
24
+
25
+ axiosRetry(axios, {retries: 3, retryDelay: axiosRetry.exponentialDelay});
26
+
27
+ const mutex = new Mutex();
28
+
29
+ interface TokenPayload {
30
+ exp: number;
31
+ }
32
+
33
+ export async function getTokenExpirationTimes(): Promise<{
34
+ refreshRemainingSecs?: number;
35
+ authRemainingSecs?: number;
36
+ }> {
37
+ let refreshToken: string | null;
38
+ let authToken: string | null;
39
+ if (!IsWeb) {
40
+ refreshToken = await SecureStore.getItemAsync("REFRESH_TOKEN");
41
+ authToken = await SecureStore.getItemAsync("AUTH_TOKEN");
42
+ } else {
43
+ // Check if we're in a browser environment (not SSR)
44
+ if (typeof window !== "undefined") {
45
+ refreshToken = await AsyncStorage.getItem("REFRESH_TOKEN");
46
+ authToken = await AsyncStorage.getItem("AUTH_TOKEN");
47
+ } else {
48
+ refreshToken = null;
49
+ authToken = null;
50
+ }
51
+ }
52
+
53
+ if (!refreshToken || !authToken) {
54
+ return {authRemainingSecs: undefined, refreshRemainingSecs: undefined};
55
+ }
56
+
57
+ const now = DateTime.now().setZone("UTC");
58
+ const refreshDecoded = jwtDecode<TokenPayload>(refreshToken);
59
+ const authDecoded = jwtDecode<TokenPayload>(authToken);
60
+
61
+ const refreshExpiration = DateTime.fromSeconds(refreshDecoded.exp).setZone("UTC");
62
+ const authExpiration = DateTime.fromSeconds(authDecoded.exp).setZone("UTC");
63
+
64
+ const refreshTimeRemaining = Math.floor(refreshExpiration.diff(now, "seconds").seconds);
65
+ const authTimeRemaining = Math.floor(authExpiration.diff(now, "seconds").seconds);
66
+
67
+ if (AUTH_DEBUG) {
68
+ log(`Refresh expires in ${refreshTimeRemaining}s, Auth expires in ${authTimeRemaining}s`);
69
+ }
70
+
71
+ return {authRemainingSecs: authTimeRemaining, refreshRemainingSecs: refreshTimeRemaining};
72
+ }
73
+
74
+ // Helper function to decode token and get expiration info
75
+ export const getFriendlyExpirationInfo = async (): Promise<string> => {
76
+ const {authRemainingSecs, refreshRemainingSecs} = await getTokenExpirationTimes();
77
+
78
+ if (authRemainingSecs === undefined && refreshRemainingSecs === undefined) {
79
+ return "No tokens available";
80
+ }
81
+
82
+ const messages: string[] = [];
83
+ if (authRemainingSecs !== undefined) {
84
+ if (authRemainingSecs <= 0) {
85
+ messages.push(`Auth token expired ${Math.abs(authRemainingSecs)} seconds ago`);
86
+ } else {
87
+ messages.push(`Auth token expires in ${authRemainingSecs} seconds`);
88
+ }
89
+ }
90
+
91
+ if (refreshRemainingSecs !== undefined) {
92
+ if (refreshRemainingSecs <= 0) {
93
+ messages.push(`Refresh token expired ${Math.abs(refreshRemainingSecs)} seconds ago`);
94
+ } else {
95
+ messages.push(`Refresh token expires in ${refreshRemainingSecs} seconds`);
96
+ }
97
+ }
98
+
99
+ return messages.join(", ");
100
+ };
101
+
102
+ // Give an extra 5 seconds to make sure if they hit the modal with 1 minute left,
103
+ // they have time to click the button
104
+ export const shouldShowStillThereModal = async (): Promise<boolean> => {
105
+ const {refreshRemainingSecs} = await getTokenExpirationTimes();
106
+ if (refreshRemainingSecs === undefined) {
107
+ return false;
108
+ }
109
+ return refreshRemainingSecs <= 65;
110
+ };
111
+
112
+ export const refreshAuthToken = async (): Promise<void> => {
113
+ let refreshToken: string | null;
114
+ if (!IsWeb) {
115
+ refreshToken = await SecureStore.getItemAsync("REFRESH_TOKEN");
116
+ } else {
117
+ // Check if we're in a browser environment (not SSR)
118
+ if (typeof window !== "undefined") {
119
+ refreshToken = await AsyncStorage.getItem("REFRESH_TOKEN");
120
+ } else {
121
+ refreshToken = null;
122
+ }
123
+ }
124
+ console.debug("Refreshing token, current token");
125
+ if (refreshToken) {
126
+ const refreshResult = await axios.post(`${baseUrl}/auth/refresh_token`, {
127
+ refreshToken,
128
+ });
129
+ console.debug("Refresh token result");
130
+ if (refreshResult?.data?.data) {
131
+ const data = refreshResult.data.data;
132
+ if (!data.token || !data.refreshToken) {
133
+ console.warn("refresh token API request didn't return data");
134
+ throw new Error("refresh token API request didn't return data");
135
+ }
136
+ if (!IsWeb) {
137
+ await SecureStore.setItemAsync("AUTH_TOKEN", data.token);
138
+ await SecureStore.setItemAsync("REFRESH_TOKEN", data.refreshToken);
139
+ } else {
140
+ // Check if we're in a browser environment (not SSR)
141
+ if (typeof window !== "undefined") {
142
+ await AsyncStorage.setItem("AUTH_TOKEN", data.token);
143
+ await AsyncStorage.setItem("REFRESH_TOKEN", data.refreshToken);
144
+ }
145
+ }
146
+ axios.defaults.headers.common.Authorization = `Bearer ${data.token}`;
147
+ console.debug("New token stored");
148
+ } else {
149
+ console.warn("refresh token API request failed or didn't return data");
150
+ throw new Error("refresh token API request failed or didn't return data");
151
+ }
152
+ } else {
153
+ console.warn("no refresh token found");
154
+ throw new Error("no refresh token found");
155
+ }
156
+ };
157
+
158
+ const getBaseQuery = (
159
+ args: string | FetchArgs,
160
+ api: BaseQueryApi,
161
+ extraOptions: unknown,
162
+ token: string | null
163
+ ) => {
164
+ const version = Constants.expoConfig?.version ?? "Unknown";
165
+
166
+ return fetchBaseQuery({
167
+ baseUrl: `${baseUrl}`,
168
+ // We need to use qs.stringify here because fetchBaseQuery uses the qs library which doesn't
169
+ // support nested objects, such as our $in, $lt/$gte, etc queries.
170
+ paramsSerializer: (params) => {
171
+ return qs.stringify(params);
172
+ },
173
+ prepareHeaders: async (headers) => {
174
+ headers.set("authorization", `Bearer ${token}`);
175
+ // Send version in case the API needs to respond differently based on version.
176
+ headers.set("App-Version", version);
177
+ headers.set("App-Platform", IsWeb ? "web" : "mobile");
178
+ return headers;
179
+ },
180
+ // We need to slightly change the format of the data coming from the API to match the format
181
+ // that the SDK generates.
182
+ responseHandler: async (response) => {
183
+ if (response.status === 204) {
184
+ return null;
185
+ }
186
+ const result = await response.json();
187
+ if ("more" in result) {
188
+ // For list responses, return the whole result
189
+ return result;
190
+ } else if (result.data) {
191
+ // For read, update, and create responses, return the data. We used to use a transformer,
192
+ // but
193
+ return result.data;
194
+ } else {
195
+ return result;
196
+ }
197
+ },
198
+ // biome-ignore lint/suspicious/noExplicitAny: Weird typing from rtk query
199
+ })(args, api, extraOptions as any);
200
+ };
201
+
202
+ const staggeredBaseQuery = retry(
203
+ async (args: string | FetchArgs, api, extraOptions) => {
204
+ // wait until the mutex is available without locking it
205
+ await mutex.waitForUnlock();
206
+ let token = await getAuthToken();
207
+
208
+ if (["emailLogin", "emailSignUp", "googleLogin", "getZoomSignature"].includes(api.endpoint)) {
209
+ // just pass thru the request without token validation for login/signup requests
210
+ // (even if there's a stale token in storage)
211
+ return getBaseQuery(args, api, extraOptions, token);
212
+ }
213
+ if (!token) {
214
+ console.debug(`No token found and the endpoint is ${api.endpoint}`);
215
+ // assume the token was removed because the user logged out and dispatch logout
216
+ api.dispatch({type: LOGOUT_ACTION_TYPE});
217
+ return {error: {error: `No token found for ${api.endpoint}`, status: "FETCH_ERROR"}};
218
+ }
219
+
220
+ const {refreshRemainingSecs, authRemainingSecs} = await getTokenExpirationTimes();
221
+ // if both auth and refresh tokens exist but are expired, log the user out
222
+ if (
223
+ authRemainingSecs &&
224
+ authRemainingSecs < 0 &&
225
+ refreshRemainingSecs &&
226
+ refreshRemainingSecs < 0
227
+ ) {
228
+ console.warn(
229
+ `[auth] Both tokens are expired, logging out: authRemainingSecs: ${authRemainingSecs}, refreshRemainingSecs: ${refreshRemainingSecs}`
230
+ );
231
+ api.dispatch({type: LOGOUT_ACTION_TYPE});
232
+ return {error: {error: "Auth and refresh tokens are expired", status: "FETCH_ERROR"}};
233
+ }
234
+
235
+ // if the auth token is within about 2 minute of expiring, refresh it automatically
236
+ if (authRemainingSecs && authRemainingSecs < 130) {
237
+ if (!mutex.isLocked()) {
238
+ const release = await mutex.acquire();
239
+ try {
240
+ log(`Refreshing token: authRemainingSecs: ${authRemainingSecs}`);
241
+ await refreshAuthToken();
242
+ token = await getAuthToken();
243
+ log(`Token refreshed: ${token}`);
244
+ api.dispatch({type: TOKEN_REFRESHED_SUCCESS});
245
+ } catch (error: unknown) {
246
+ if (axios.isAxiosError(error)) {
247
+ // if it is a Network Error, don't auto log out and just let the next request go
248
+ // through
249
+ console.warn(`[auth] Network error refreshing token: ${error.code} ${error.message}`);
250
+ if (error.code === "ERR_NETWORK") {
251
+ return getBaseQuery(args, api, extraOptions, token);
252
+ } else if (error.status === 401) {
253
+ api.dispatch({type: LOGOUT_ACTION_TYPE});
254
+ return {error: {error: "Token refresh failed with 401", status: "FETCH_ERROR"}};
255
+ }
256
+ }
257
+ console.warn(
258
+ `[auth] Error refreshing token: ${error instanceof Error ? error.message : String(error)}`
259
+ );
260
+ api.dispatch({type: LOGOUT_ACTION_TYPE});
261
+ return {
262
+ error: {
263
+ error: `Failed to refresh token: ${error instanceof Error ? error.message : String(error)}`,
264
+ status: "FETCH_ERROR",
265
+ },
266
+ };
267
+ } finally {
268
+ release();
269
+ }
270
+ }
271
+ } else {
272
+ // wait until the mutex is available
273
+ log(`Waiting for mutex to get token: authRemainingSecs: ${authRemainingSecs}`);
274
+ await mutex.waitForUnlock();
275
+ token = await getAuthToken();
276
+ }
277
+
278
+ let baseQuery = getBaseQuery(args, api, extraOptions, token);
279
+ let result = await baseQuery;
280
+
281
+ if (result.error?.status === 401) {
282
+ if (!mutex.isLocked()) {
283
+ console.warn("[auth] 401 error, refreshing token and retrying, waiting for mutex");
284
+ const release = await mutex.acquire();
285
+ log("401 error, refreshing token and retrying, got mutex");
286
+ try {
287
+ await refreshAuthToken();
288
+ token = await getAuthToken();
289
+ log(`401 error, refreshing token and retrying, got new token: ${token}`);
290
+ api.dispatch({type: TOKEN_REFRESHED_SUCCESS});
291
+ baseQuery = getBaseQuery(args, api, extraOptions, token);
292
+ // retry once with the new token before failing and logging out
293
+ result = await baseQuery;
294
+ } catch (error: unknown) {
295
+ console.error(
296
+ "Error refreshing auth token",
297
+ error instanceof Error ? error.message : String(error)
298
+ );
299
+ api.dispatch({type: LOGOUT_ACTION_TYPE});
300
+ } finally {
301
+ release();
302
+ }
303
+ } else {
304
+ // wait until the mutex is available without locking it then try again since got 401 on
305
+ // first try
306
+ console.warn(
307
+ "401 error and mutex locked, refreshing token and retrying, waiting for mutex"
308
+ );
309
+ await mutex.waitForUnlock();
310
+ token = await getAuthToken();
311
+ log(`401 error and mutex locked, refreshing token and retrying, got new token: ${token}`);
312
+ baseQuery = getBaseQuery(args, api, extraOptions, token);
313
+ result = await baseQuery;
314
+ }
315
+ // if any other type of error, don't retry if it is a mutation to prevent potential duplicates
316
+ } else if (result.error && api.type === "mutation") {
317
+ log(`Error on mutation, not retrying, ${authRemainingSecs}`);
318
+ retry.fail(result.error);
319
+ }
320
+ return result;
321
+ },
322
+ {
323
+ maxRetries: 3,
324
+ }
325
+ );
326
+
327
+ // initialize an empty api service that we'll inject endpoints into later as needed
328
+ export const emptySplitApi = createApi({
329
+ baseQuery: staggeredBaseQuery,
330
+ endpoints: (builder) => ({
331
+ // biome-ignore lint/suspicious/noExplicitAny: Generic
332
+ ...generateProfileEndpoints(builder as any, "users"), // using 'users' here since it is highly intertwined with Users
333
+ }),
334
+ reducerPath: "terreno-rtk",
335
+ });
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from "./authSlice";
2
+ export * from "./constants";
3
+ export * from "./emptyApi";
4
+ export * from "./mongooseSlice";
5
+ export * from "./platform";
6
+ export * from "./socket";
7
+ export * from "./tagGenerator";
@@ -0,0 +1,19 @@
1
+ export interface ListResponse<T> {
2
+ page?: number;
3
+ limit?: number;
4
+ more?: boolean;
5
+ total?: number;
6
+ data?: T[];
7
+ }
8
+
9
+ // Given an ID and the {data} from a list query, return the object with that ID.
10
+ // Does not fill in the object like populating in Mongoose.
11
+ export function populateId<T extends {_id: string}>(
12
+ id?: string,
13
+ objs?: ListResponse<T>
14
+ ): T | undefined {
15
+ if (!id || !objs) {
16
+ return undefined;
17
+ }
18
+ return objs?.data?.find((obj) => obj?._id === id);
19
+ }
@@ -0,0 +1,3 @@
1
+ import {Platform} from "react-native";
2
+
3
+ export const IsWeb = Platform.OS === "web";