@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.
package/src/socket.ts ADDED
@@ -0,0 +1,436 @@
1
+ import {useToast} from "@terreno/ui";
2
+ import {DateTime} from "luxon";
3
+ import {useCallback, useEffect, useRef, useState} from "react";
4
+ import {useSelector} from "react-redux";
5
+ import {io, type Socket} from "socket.io-client";
6
+ import {selectLastTokenRefreshTimestamp} from "./authSlice";
7
+ import {logAuth} from "./constants";
8
+ import {getFriendlyExpirationInfo, getTokenExpirationTimes, refreshAuthToken} from "./emptyApi";
9
+
10
+ export interface SocketConnection {
11
+ isConnected: boolean;
12
+ lastDisconnectedAt: string | null;
13
+ }
14
+
15
+ export interface UseSocketConnectionOptions {
16
+ baseUrl: string;
17
+ onConnect?: () => void;
18
+ onDisconnect?: () => void;
19
+ onConnectError?: (error: Error) => void;
20
+ onReconnectFailed?: () => void;
21
+ getAuthToken: () => Promise<string | null>;
22
+ shouldConnect: boolean;
23
+ captureEvent?: (eventName: string, data: Record<string, unknown>) => void;
24
+ }
25
+
26
+ export const useSocketConnection = ({
27
+ baseUrl,
28
+ onConnect,
29
+ onDisconnect,
30
+ onConnectError,
31
+ onReconnectFailed,
32
+ getAuthToken,
33
+ shouldConnect, // Whether we have a logged in user.
34
+ captureEvent,
35
+ }: UseSocketConnectionOptions): {
36
+ socket: Socket | null;
37
+ isSocketConnected: SocketConnection;
38
+ } => {
39
+ const toast = useToast();
40
+ const [socket, setSocket] = useState<Socket | null>(null);
41
+ const isConnectedRef = useRef<SocketConnection>(undefined);
42
+ const [isSocketConnected, setIsSocketConnected] = useState<SocketConnection>({
43
+ isConnected: socket?.connected ?? false,
44
+ lastDisconnectedAt: null,
45
+ });
46
+ const disconnectedToastId = useRef<string | null>(null);
47
+ const tokenErrorToastId = useRef<string | null>(null);
48
+
49
+ // Keep ref updated with latest socket connection state
50
+ useEffect(() => {
51
+ isConnectedRef.current = isSocketConnected;
52
+ }, [isSocketConnected]);
53
+
54
+ // Initialize socket connection
55
+ useEffect(() => {
56
+ const socketIo = io(baseUrl, {
57
+ autoConnect: false,
58
+ reconnection: true,
59
+ reconnectionAttempts: 5,
60
+ reconnectionDelay: 1000,
61
+ reconnectionDelayMax: 5000,
62
+ transports: ["websocket"],
63
+ });
64
+
65
+ setSocket(socketIo);
66
+
67
+ return (): void => {
68
+ socketIo.disconnect();
69
+ };
70
+ }, [baseUrl]);
71
+
72
+ const hideDisconnectedToast = useCallback((): void => {
73
+ if (disconnectedToastId.current) {
74
+ toast.hide(disconnectedToastId.current);
75
+ disconnectedToastId.current = null;
76
+ }
77
+ }, [toast]);
78
+
79
+ const hideTokenErrorToast = useCallback((): void => {
80
+ if (tokenErrorToastId.current) {
81
+ toast.hide(tokenErrorToastId.current);
82
+ tokenErrorToastId.current = null;
83
+ }
84
+ }, [toast]);
85
+
86
+ // Connect the socket with the current auth token
87
+ const connectSocket = useCallback(async (): Promise<void> => {
88
+ const token = await getAuthToken();
89
+
90
+ if (!token) {
91
+ console.warn(
92
+ "[SocketConnection] Attempting to connect socket, but getAuthToken returned no token."
93
+ );
94
+ // Don't capture this event because it's expected when the user is logged out.
95
+ return;
96
+ } else {
97
+ logAuth("[SocketConnection] Token received from getAuthToken.");
98
+ }
99
+
100
+ if (socket) {
101
+ // Enhanced logging for Option 1 (token status)
102
+ logAuth(
103
+ `[SocketConnection] Socket connecting ${token ? "with" : "without"} token. Current socket state: ${socket.connected ? "connected" : "disconnected"}`
104
+ );
105
+ socket.auth = {token: `Bearer ${token}`};
106
+ socket.connect();
107
+ } else {
108
+ console.warn("[SocketConnection] connectSocket called but socket instance is null.");
109
+ }
110
+ }, [socket, getAuthToken]);
111
+
112
+ // Extracted logic for checking token expiration, refreshing token, and handling related UI
113
+ const checkAndRefreshTokenLogic = useCallback(
114
+ async (context: "disconnect" | "connect_error"): Promise<void> => {
115
+ let authRemainingSecs: number | undefined;
116
+ let refreshRemainingSecs: number | undefined;
117
+ try {
118
+ const expirationTimes = await getTokenExpirationTimes();
119
+ authRemainingSecs = expirationTimes.authRemainingSecs;
120
+ refreshRemainingSecs = expirationTimes.refreshRemainingSecs;
121
+ logAuth(
122
+ `[SocketConnection] Token status on ${context}: authRemainingSecs: ${authRemainingSecs}, refreshRemainingSecs: ${refreshRemainingSecs}`
123
+ );
124
+ if (
125
+ (authRemainingSecs !== undefined && authRemainingSecs < 60) ||
126
+ (refreshRemainingSecs !== undefined && refreshRemainingSecs < 60)
127
+ ) {
128
+ logAuth(
129
+ `[SocketConnection] Auth or refresh token nearing expiration or expired on ${context}, attempting refresh.`
130
+ );
131
+ await refreshAuthToken();
132
+ // Attempt to reconnect after token refresh
133
+ if (shouldConnect && socket && !socket.connected) {
134
+ logAuth(
135
+ `[SocketConnection] Attempting to reconnect socket after token refresh due to ${context}.`
136
+ );
137
+ socket.connect();
138
+ }
139
+ }
140
+ } catch (error) {
141
+ const socketError = error as Error;
142
+ console.error(
143
+ `[SocketConnection] Error checking/refreshing token on ${context}:`,
144
+ socketError
145
+ );
146
+ if (refreshRemainingSecs !== undefined && refreshRemainingSecs > 0) {
147
+ const tokenInfo = await getFriendlyExpirationInfo();
148
+ // Only capture this event if the refresh token is still valid,
149
+ // otherwise it's expected it will fail.
150
+ captureEvent?.(
151
+ `WebSocket Token Check/Refresh Error on ${context === "disconnect" ? "Disconnect" : "ConnectError"}`,
152
+ {
153
+ authRemainingSecs,
154
+ error: socketError.message,
155
+ refreshRemainingSecs,
156
+ time: DateTime.now().toISO(),
157
+ tokenInfo,
158
+ }
159
+ );
160
+ }
161
+ hideDisconnectedToast();
162
+ if (!tokenErrorToastId.current) {
163
+ tokenErrorToastId.current = toast.show(
164
+ "Error refreshing token. Please log out and log back in if reconnections fail. Your work may not be saved if you continue.",
165
+ {
166
+ onDismiss: (): void => hideTokenErrorToast(),
167
+ persistent: true,
168
+ variant: "error",
169
+ }
170
+ );
171
+ }
172
+ }
173
+ },
174
+ [shouldConnect, socket, captureEvent, hideDisconnectedToast, toast, hideTokenErrorToast]
175
+ );
176
+
177
+ // Use Redux state for token refresh signal
178
+ const lastTokenRefreshTimestamp = useSelector(selectLastTokenRefreshTimestamp);
179
+ const previousTokenRefreshTimestampRef = useRef<number | null>(null);
180
+
181
+ // Effect to handle token refresh events from Redux state
182
+ useEffect(() => {
183
+ if (
184
+ lastTokenRefreshTimestamp &&
185
+ lastTokenRefreshTimestamp !== previousTokenRefreshTimestampRef.current
186
+ ) {
187
+ if (tokenErrorToastId.current) {
188
+ logAuth(
189
+ "[SocketConnection] Token refresh detected via Redux state, dismissing error toast and attempting reconnect."
190
+ );
191
+ hideTokenErrorToast();
192
+ }
193
+ if (shouldConnect && !socket?.connected) {
194
+ logAuth(
195
+ "[SocketConnection] Attempting to connect socket after token refresh detected via Redux state."
196
+ );
197
+ void connectSocket();
198
+ }
199
+ }
200
+ previousTokenRefreshTimestampRef.current = lastTokenRefreshTimestamp;
201
+ }, [lastTokenRefreshTimestamp, socket, shouldConnect, connectSocket, hideTokenErrorToast]);
202
+
203
+ // Connect/disconnect socket based on shouldConnect flag
204
+ useEffect(() => {
205
+ if (shouldConnect) {
206
+ if (!isSocketConnected.isConnected) {
207
+ logAuth(
208
+ `[SocketConnection] Attempting to connect socket because shouldConnect is true and socket is not connected.`
209
+ );
210
+ void connectSocket();
211
+ } else {
212
+ logAuth(
213
+ `[SocketConnection] Socket is already connected and shouldConnect is true. No action needed.`
214
+ );
215
+ }
216
+ } else {
217
+ if (isSocketConnected.isConnected) {
218
+ logAuth(
219
+ `[SocketConnection] Attempting to disconnect socket because shouldConnect is false and socket is connected.`
220
+ );
221
+ socket?.disconnect();
222
+ setIsSocketConnected({
223
+ isConnected: false,
224
+ lastDisconnectedAt: null, // null because this was intentional
225
+ });
226
+ } else {
227
+ logAuth(
228
+ `[SocketConnection] Socket is already disconnected and shouldConnect is false. No action needed.`
229
+ );
230
+ }
231
+ }
232
+ }, [connectSocket, shouldConnect, isSocketConnected, socket]);
233
+
234
+ // Attempt to reconnect if token was refreshed and we are disconnected
235
+ useEffect(() => {
236
+ if (shouldConnect && !isSocketConnected.isConnected && socket) {
237
+ logAuth("[SocketConnection] Token refresh detected, attempting to reconnect socket.");
238
+ // We might want to ensure the socket isn't already in a connecting state here
239
+ // if socket.io-client provides such a state.
240
+ // Forcing a disconnect first can help if it's stuck in a bad state.
241
+ socket.disconnect();
242
+ void connectSocket();
243
+ }
244
+ }, [shouldConnect, isSocketConnected.isConnected, socket, connectSocket]);
245
+
246
+ // Show toast when disconnected
247
+ useEffect(() => {
248
+ if (!shouldConnect) {
249
+ return;
250
+ }
251
+
252
+ const checkShowToast = async (): Promise<void> => {
253
+ // if there is an error toast, don't show the disconnect toast
254
+ if (tokenErrorToastId.current) {
255
+ return;
256
+ }
257
+ const shouldShowDisconnectToast =
258
+ !isConnectedRef.current?.isConnected &&
259
+ isConnectedRef.current?.lastDisconnectedAt &&
260
+ DateTime.now().diff(DateTime.fromISO(isConnectedRef.current.lastDisconnectedAt), "seconds")
261
+ .seconds > 9;
262
+
263
+ if (shouldShowDisconnectToast && !disconnectedToastId.current) {
264
+ disconnectedToastId.current = toast.show(
265
+ "You have been disconnected. Attempting to reconnect...",
266
+ {
267
+ onDismiss: (): void => hideDisconnectedToast(),
268
+ persistent: true,
269
+ }
270
+ );
271
+ } else if (!shouldShowDisconnectToast && disconnectedToastId.current) {
272
+ // If we should no longer show the toast but it is still showing, hide it
273
+ hideDisconnectedToast();
274
+ }
275
+ };
276
+
277
+ let intervalId: ReturnType<typeof setInterval> | null = null;
278
+
279
+ // Check every second if we've reconnected
280
+ const startCheckingConnection = (): void => {
281
+ if (!isConnectedRef.current?.isConnected && !intervalId) {
282
+ intervalId = setInterval(async () => {
283
+ await checkShowToast();
284
+ if (isConnectedRef.current?.isConnected && intervalId) {
285
+ clearInterval(intervalId);
286
+ intervalId = null;
287
+ }
288
+ }, 1000);
289
+ }
290
+ };
291
+
292
+ startCheckingConnection();
293
+
294
+ return (): void => {
295
+ if (intervalId) {
296
+ clearInterval(intervalId);
297
+ }
298
+ };
299
+ }, [hideDisconnectedToast, shouldConnect, toast]);
300
+
301
+ // Set up basic socket event listeners
302
+ useEffect(() => {
303
+ if (!socket) return;
304
+
305
+ const handleConnect = (): void => {
306
+ logAuth("[SocketConnection] Socket connected");
307
+ hideDisconnectedToast();
308
+ hideTokenErrorToast();
309
+
310
+ // don't show toast if was disconnected and now connected within 10 seconds
311
+ if (
312
+ isSocketConnected.lastDisconnectedAt &&
313
+ DateTime.now().diff(DateTime.fromISO(isSocketConnected.lastDisconnectedAt), "seconds")
314
+ .seconds > 10
315
+ ) {
316
+ toast.show("You have been reconnected.");
317
+ }
318
+
319
+ setIsSocketConnected({
320
+ isConnected: true,
321
+ lastDisconnectedAt: null,
322
+ });
323
+
324
+ onConnect?.();
325
+ };
326
+
327
+ const handleDisconnect = async (reason: Socket.DisconnectReason): Promise<void> => {
328
+ const tokenInfo = await getFriendlyExpirationInfo();
329
+
330
+ // Enhanced logging for Option 1 (disconnect reason)
331
+ logAuth(
332
+ `[SocketConnection] Socket disconnected, reason: ${reason}, token status: ${tokenInfo}`
333
+ );
334
+ setIsSocketConnected({
335
+ isConnected: false,
336
+ lastDisconnectedAt: DateTime.now().toISO(),
337
+ });
338
+
339
+ captureEvent?.("WebSocket Disconnection", {
340
+ reason,
341
+ time: DateTime.now().toISO(),
342
+ tokenInfo,
343
+ });
344
+
345
+ // Check token status on disconnect
346
+ await checkAndRefreshTokenLogic("disconnect");
347
+
348
+ await onDisconnect?.();
349
+ };
350
+
351
+ const handleConnectError = async (connectionError: Error): Promise<void> => {
352
+ const tokenInfo = await getFriendlyExpirationInfo();
353
+
354
+ console.error(
355
+ "[SocketConnection] Socket connection error:",
356
+ connectionError,
357
+ "Token status:",
358
+ tokenInfo
359
+ );
360
+ captureEvent?.("WebSocket Connection Error", {
361
+ error: connectionError.message,
362
+ time: DateTime.now().toISO(),
363
+ tokenInfo,
364
+ });
365
+
366
+ // Check token status on connect_error
367
+ await checkAndRefreshTokenLogic("connect_error");
368
+
369
+ onConnectError?.(connectionError);
370
+ };
371
+
372
+ const handleReconnectFailed = async (): Promise<void> => {
373
+ const tokenInfo = await getFriendlyExpirationInfo();
374
+
375
+ console.error(
376
+ "[SocketConnection] Socket reconnection failed after exhausting reconnection attempts.",
377
+ "Token status:",
378
+ tokenInfo
379
+ );
380
+ captureEvent?.("WebSocket Reconnect Failed", {
381
+ time: DateTime.now().toISO(),
382
+ tokenInfo,
383
+ });
384
+
385
+ // Force a new connection attempt
386
+ socket.disconnect();
387
+ setTimeout(() => {
388
+ // Check shouldConnect and if still disconnected using the ref for the most current state
389
+ if (shouldConnect && isConnectedRef.current && !isConnectedRef.current.isConnected) {
390
+ logAuth(
391
+ "[SocketConnection] Attempting to force a new connection after reconnect_failed event."
392
+ );
393
+ void connectSocket();
394
+ } else if (!shouldConnect) {
395
+ logAuth(
396
+ "[SocketConnection] Not attempting to reconnect after reconnect_failed because shouldConnect is false."
397
+ );
398
+ } else if (isConnectedRef.current?.isConnected) {
399
+ logAuth(
400
+ "[SocketConnection] Not attempting to reconnect after reconnect_failed because socket is now connected."
401
+ );
402
+ }
403
+ }, 2000);
404
+ onReconnectFailed?.();
405
+ };
406
+
407
+ // Attach event listeners
408
+ socket.on("connect", handleConnect);
409
+ socket.on("disconnect", handleDisconnect);
410
+ socket.on("connect_error", handleConnectError);
411
+ socket.on("reconnect_failed", handleReconnectFailed);
412
+
413
+ return (): void => {
414
+ socket.off("connect", handleConnect);
415
+ socket.off("disconnect", handleDisconnect);
416
+ socket.off("connect_error", handleConnectError);
417
+ socket.off("reconnect_failed", handleReconnectFailed);
418
+ };
419
+ }, [
420
+ socket,
421
+ hideDisconnectedToast,
422
+ isSocketConnected.lastDisconnectedAt,
423
+ captureEvent,
424
+ onConnect,
425
+ onDisconnect,
426
+ onConnectError,
427
+ onReconnectFailed,
428
+ shouldConnect,
429
+ connectSocket,
430
+ toast,
431
+ hideTokenErrorToast,
432
+ checkAndRefreshTokenLogic,
433
+ ]);
434
+
435
+ return {isSocketConnected, socket};
436
+ };
@@ -0,0 +1,82 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: Generics
2
+
3
+ // use this with enhanceEndpoints since the code generator doesn't invalidate by individual ids,
4
+ // only at the full collection level
5
+
6
+ const providesIdTags =
7
+ (path: string) =>
8
+ (result: any): string[] | [{type: string; id?: string}] =>
9
+ result ? [...(result?.data?.map(({_id}: any) => ({id: _id, type: path})) ?? []), path] : [path];
10
+
11
+ const providesIdTag =
12
+ (path: string) =>
13
+ (result: any): string[] | [{type: string; id?: string}] => {
14
+ return result ? [{id: result._id, type: path}] : [path];
15
+ };
16
+
17
+ const invalidatesIdTags =
18
+ (path: string) =>
19
+ (result: any): string[] | [{type: string; id?: string}] =>
20
+ result ? [...(result?.data?.map(({_id}: any) => ({id: _id, type: path})) ?? []), path] : [path];
21
+
22
+ const cleanEndpointStringToGenerateTag = (string: string): string => {
23
+ // Define the prefixes and suffix
24
+ const prefixes = ["patch", "get", "delete"];
25
+ const suffix = "ById";
26
+
27
+ // Create a regular expression to match the prefixes and suffix
28
+ const prefixPattern = `^(${prefixes.join("|")})`;
29
+ const suffixPattern = `${suffix}$`;
30
+ const regex = new RegExp(`${prefixPattern}|${suffixPattern}`, "gi");
31
+
32
+ // Replace the matched parts and convert to lowercase
33
+ return string.replace(regex, "")?.toLowerCase();
34
+ };
35
+
36
+ export const generateTags = (api: any, tagTypes: string[]): any => {
37
+ // take the api, and for each get and list endpoint, generate tags that invalidate the cache by id
38
+ // and by the list endpoint
39
+ const endpoints = api.endpoints;
40
+ const tags: any = {};
41
+ Object.keys(endpoints).forEach((endpoint) => {
42
+ if (endpoint === "getConversations") {
43
+ tags[endpoint] = {invalidatesTags: ["conversations", "messages"]};
44
+ }
45
+ if (endpoint.toLowerCase().includes("get")) {
46
+ // List endpoints
47
+ if (!endpoint.toLowerCase().includes("byid")) {
48
+ const tag = tagTypes.find((t: string) =>
49
+ // remove "get" from the endpoint name and "ById" from the endpoint name
50
+ t
51
+ .toLowerCase()
52
+ .includes(cleanEndpointStringToGenerateTag(endpoint))
53
+ );
54
+ if (tag) {
55
+ tags[endpoint] = {providesTags: providesIdTags(tag)};
56
+ }
57
+ }
58
+ // Read endpoints
59
+ else {
60
+ const tag = tagTypes.find((t: string) =>
61
+ t.toLowerCase().includes(cleanEndpointStringToGenerateTag(endpoint))
62
+ );
63
+ if (tag) {
64
+ tags[endpoint] = {providesTags: providesIdTag(tag)};
65
+ }
66
+ }
67
+ }
68
+ // Patch and delete endpoints
69
+ else if (
70
+ endpoint.toLowerCase().includes("patch") ||
71
+ endpoint.toLowerCase().includes("delete")
72
+ ) {
73
+ const tag = tagTypes.find((t: string) =>
74
+ t.toLowerCase().includes(cleanEndpointStringToGenerateTag(endpoint))
75
+ );
76
+ if (tag) {
77
+ tags[endpoint] = {invalidatesTags: invalidatesIdTags(tag)};
78
+ }
79
+ }
80
+ });
81
+ return tags;
82
+ };