@oxyhq/auth 2.0.3 → 2.0.4

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.
@@ -1,5 +1,4 @@
1
1
  import { useEffect, useRef, useMemo } from 'react';
2
- import io from 'socket.io-client';
3
2
  import { toast } from 'sonner';
4
3
  import { logger } from '@oxyhq/core';
5
4
  import { createDebugLogger } from '@oxyhq/core';
@@ -10,6 +9,49 @@ import { invalidateSessionQueries } from './queries/queryKeys';
10
9
 
11
10
  const debug = createDebugLogger('SessionSocket');
12
11
 
12
+ /** localStorage key used by AuthManager for persisting access tokens. */
13
+ const LS_ACCESS_TOKEN_KEY = 'oxy_access_token';
14
+
15
+ /** Delay before retrying socket connection after an auth failure (ms). */
16
+ const AUTH_RETRY_DELAY_MS = 2000;
17
+
18
+ /** Maximum number of consecutive auth-failure retries. */
19
+ const MAX_AUTH_RETRIES = 3;
20
+
21
+ /**
22
+ * Read the access token from localStorage directly.
23
+ * Used as a fallback when the in-memory token is empty (e.g., during a
24
+ * cross-tab token refresh race).
25
+ */
26
+ function readTokenFromStorage(): string | null {
27
+ if (typeof window === 'undefined') return null;
28
+ try {
29
+ return window.localStorage.getItem(LS_ACCESS_TOKEN_KEY);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ type SocketIOFactory = (uri: string, opts?: Record<string, unknown>) => unknown;
36
+
37
+ let _io: SocketIOFactory | null = null;
38
+ let _ioLoadAttempted = false;
39
+
40
+ async function getSocketIO(): Promise<SocketIOFactory | null> {
41
+ if (_io) return _io;
42
+ if (_ioLoadAttempted) return null;
43
+ _ioLoadAttempted = true;
44
+ try {
45
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
46
+ const mod: any = await import('socket.io-client');
47
+ _io = (mod.io ?? mod.default) as SocketIOFactory;
48
+ return _io;
49
+ } catch {
50
+ debug.warn('socket.io-client is not installed. useSessionSocket will be disabled. Install it with: bun add socket.io-client');
51
+ return null;
52
+ }
53
+ }
54
+
13
55
  export interface UseSessionSocketOptions {
14
56
  onRemoteSignOut?: () => void;
15
57
  onSessionRemoved?: (sessionId: string) => void;
@@ -66,139 +108,226 @@ export function useSessionSocket(options?: UseSessionSocketOptions) {
66
108
  socketRef.current = null;
67
109
  }
68
110
 
69
- // Connect with auth token; use callback so reconnections get a fresh token
70
- socketRef.current = io(baseURL, {
71
- transports: ['websocket'],
72
- auth: (cb: (data: { token: string }) => void) => {
73
- const token = oxyServices.getAccessToken();
74
- cb({ token: token ?? '' });
75
- },
76
- });
77
- const socket = socketRef.current;
111
+ let cancelled = false;
112
+ let authRetryCount = 0;
113
+ let authRetryTimer: ReturnType<typeof setTimeout> | null = null;
78
114
 
79
- // Server auto-joins the user to `user:<userId>` room on connection
80
- const handleConnect = () => {
81
- debug.log('Socket connected:', socket.id);
115
+ /**
116
+ * Resolve the best available access token.
117
+ * Prefers the in-memory token from OxyServices; falls back to
118
+ * localStorage which may have been updated by another tab.
119
+ */
120
+ const resolveToken = (): string | null => {
121
+ return oxyServices.getAccessToken() || readTokenFromStorage();
82
122
  };
83
123
 
84
- const refreshSessions = () => {
85
- invalidateSessionQueries(queryClientRef.current);
86
- return Promise.resolve();
87
- };
124
+ getSocketIO().then((ioFn) => {
125
+ if (cancelled || !ioFn) return;
88
126
 
89
- const handleSessionUpdate = async (data: {
90
- type: string;
91
- sessionId?: string;
92
- deviceId?: string;
93
- sessionIds?: string[]
94
- }) => {
95
- debug.log('Received session_update:', data);
96
-
97
- const currentActiveSessionId = activeSessionIdRef.current;
98
- const deviceId = currentDeviceIdRef.current;
99
-
100
- // Handle different event types
101
- if (data.type === 'session_removed') {
102
- // Track removed session
103
- if (data.sessionId && onSessionRemovedRef.current) {
104
- onSessionRemovedRef.current(data.sessionId);
105
- }
106
-
107
- // If the removed sessionId matches the current activeSessionId, immediately clear state
108
- if (data.sessionId === currentActiveSessionId) {
109
- if (onRemoteSignOutRef.current) {
110
- onRemoteSignOutRef.current();
111
- } else {
112
- toast.info('You have been signed out remotely.');
113
- }
114
- try {
115
- await clearSessionStateRef.current();
116
- } catch (error) {
117
- if (__DEV__) {
118
- logger.error('Failed to clear session state after session_removed', error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
127
+ // Connect with auth token; use callback so reconnections get a fresh token.
128
+ // If no token is available at all, we skip the initial connect and let
129
+ // the storage listener or retry logic connect when a token appears.
130
+ const token = resolveToken();
131
+ socketRef.current = ioFn(baseURL, {
132
+ transports: ['websocket'],
133
+ autoConnect: !!token, // don't auto-connect when there is no token
134
+ auth: (cb: (data: { token: string }) => void) => {
135
+ const resolved = resolveToken();
136
+ if (!resolved) {
137
+ // No token available -- disconnect gracefully instead of sending
138
+ // an empty string that the server will reject.
139
+ debug.warn('No access token available for socket auth; disconnecting.');
140
+ if (socketRef.current) {
141
+ socketRef.current.disconnect();
119
142
  }
143
+ return;
120
144
  }
121
- } else {
122
- refreshSessions();
145
+ cb({ token: resolved });
146
+ },
147
+ });
148
+ const socket = socketRef.current;
149
+
150
+ // Server auto-joins the user to `user:<userId>` room on connection
151
+ const handleConnect = () => {
152
+ debug.log('Socket connected:', socket.id);
153
+ // Successful connection resets the auth retry counter.
154
+ authRetryCount = 0;
155
+ };
156
+
157
+ /**
158
+ * Handle socket disconnection. When the disconnect reason indicates an
159
+ * auth failure (server rejected the token), schedule a short retry so
160
+ * that an in-progress token refresh can complete before the next attempt.
161
+ */
162
+ const handleDisconnect = (reason: string) => {
163
+ debug.log('Socket disconnected:', reason);
164
+ // "io server disconnect" = server forcibly closed the connection (auth failure).
165
+ // "transport error" can also happen when the auth callback aborted.
166
+ if (
167
+ (reason === 'io server disconnect' || reason === 'transport error') &&
168
+ authRetryCount < MAX_AUTH_RETRIES &&
169
+ !cancelled
170
+ ) {
171
+ authRetryCount++;
172
+ debug.log(
173
+ `Auth-related disconnect; scheduling retry ${authRetryCount}/${MAX_AUTH_RETRIES} in ${AUTH_RETRY_DELAY_MS}ms`
174
+ );
175
+ authRetryTimer = setTimeout(() => {
176
+ if (cancelled) return;
177
+ const retryToken = resolveToken();
178
+ if (retryToken && socketRef.current) {
179
+ debug.log('Retrying socket connection with refreshed token');
180
+ socketRef.current.connect();
181
+ }
182
+ }, AUTH_RETRY_DELAY_MS);
123
183
  }
124
- } else if (data.type === 'device_removed') {
125
- // Track all removed sessions from this device
126
- if (data.sessionIds && onSessionRemovedRef.current) {
127
- for (const sessionId of data.sessionIds) {
128
- onSessionRemovedRef.current(sessionId);
184
+ };
185
+
186
+ const refreshSessions = () => {
187
+ invalidateSessionQueries(queryClientRef.current);
188
+ return Promise.resolve();
189
+ };
190
+
191
+ const handleSessionUpdate = async (data: {
192
+ type: string;
193
+ sessionId?: string;
194
+ deviceId?: string;
195
+ sessionIds?: string[]
196
+ }) => {
197
+ debug.log('Received session_update:', data);
198
+
199
+ const currentActiveSessionId = activeSessionIdRef.current;
200
+ const deviceId = currentDeviceIdRef.current;
201
+
202
+ // Handle different event types
203
+ if (data.type === 'session_removed') {
204
+ // Track removed session
205
+ if (data.sessionId && onSessionRemovedRef.current) {
206
+ onSessionRemovedRef.current(data.sessionId);
129
207
  }
130
- }
131
208
 
132
- // If the removed deviceId matches the current device, immediately clear state
133
- if (data.deviceId && data.deviceId === deviceId) {
134
- if (onRemoteSignOutRef.current) {
135
- onRemoteSignOutRef.current();
209
+ // If the removed sessionId matches the current activeSessionId, immediately clear state
210
+ if (data.sessionId === currentActiveSessionId) {
211
+ if (onRemoteSignOutRef.current) {
212
+ onRemoteSignOutRef.current();
213
+ } else {
214
+ toast.info('You have been signed out remotely.');
215
+ }
216
+ try {
217
+ await clearSessionStateRef.current();
218
+ } catch (error) {
219
+ if (process.env.NODE_ENV !== 'production') {
220
+ logger.error('Failed to clear session state after session_removed', error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
221
+ }
222
+ }
136
223
  } else {
137
- toast.info('This device has been removed. You have been signed out.');
224
+ refreshSessions();
138
225
  }
139
- try {
140
- await clearSessionStateRef.current();
141
- } catch (error) {
142
- if (__DEV__) {
143
- logger.error('Failed to clear session state after device_removed', error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
226
+ } else if (data.type === 'device_removed') {
227
+ // Track all removed sessions from this device
228
+ if (data.sessionIds && onSessionRemovedRef.current) {
229
+ for (const sessionId of data.sessionIds) {
230
+ onSessionRemovedRef.current(sessionId);
144
231
  }
145
232
  }
146
- } else {
147
- refreshSessions();
148
- }
149
- } else if (data.type === 'sessions_removed') {
150
- // Track all removed sessions
151
- if (data.sessionIds && onSessionRemovedRef.current) {
152
- for (const sessionId of data.sessionIds) {
153
- onSessionRemovedRef.current(sessionId);
154
- }
155
- }
156
233
 
157
- // If the current activeSessionId is in the removed sessionIds list, immediately clear state
158
- if (data.sessionIds && currentActiveSessionId && data.sessionIds.includes(currentActiveSessionId)) {
159
- if (onRemoteSignOutRef.current) {
160
- onRemoteSignOutRef.current();
234
+ // If the removed deviceId matches the current device, immediately clear state
235
+ if (data.deviceId && data.deviceId === deviceId) {
236
+ if (onRemoteSignOutRef.current) {
237
+ onRemoteSignOutRef.current();
238
+ } else {
239
+ toast.info('This device has been removed. You have been signed out.');
240
+ }
241
+ try {
242
+ await clearSessionStateRef.current();
243
+ } catch (error) {
244
+ if (process.env.NODE_ENV !== 'production') {
245
+ logger.error('Failed to clear session state after device_removed', error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
246
+ }
247
+ }
161
248
  } else {
162
- toast.info('You have been signed out remotely.');
249
+ refreshSessions();
163
250
  }
164
- try {
165
- await clearSessionStateRef.current();
166
- } catch (error) {
167
- if (__DEV__) {
168
- logger.error('Failed to clear session state after sessions_removed', error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
251
+ } else if (data.type === 'sessions_removed') {
252
+ // Track all removed sessions
253
+ if (data.sessionIds && onSessionRemovedRef.current) {
254
+ for (const sessionId of data.sessionIds) {
255
+ onSessionRemovedRef.current(sessionId);
169
256
  }
170
257
  }
171
- } else {
172
- refreshSessions();
173
- }
174
- } else {
175
- // For other event types (e.g., session_created), refresh sessions
176
- refreshSessions();
177
-
178
- // If the current session was logged out (legacy behavior), handle it specially
179
- if (data.sessionId === currentActiveSessionId) {
180
- if (onRemoteSignOutRef.current) {
181
- onRemoteSignOutRef.current();
258
+
259
+ // If the current activeSessionId is in the removed sessionIds list, immediately clear state
260
+ if (data.sessionIds && currentActiveSessionId && data.sessionIds.includes(currentActiveSessionId)) {
261
+ if (onRemoteSignOutRef.current) {
262
+ onRemoteSignOutRef.current();
263
+ } else {
264
+ toast.info('You have been signed out remotely.');
265
+ }
266
+ try {
267
+ await clearSessionStateRef.current();
268
+ } catch (error) {
269
+ if (process.env.NODE_ENV !== 'production') {
270
+ logger.error('Failed to clear session state after sessions_removed', error instanceof Error ? error : new Error(String(error)), { component: 'useSessionSocket' });
271
+ }
272
+ }
182
273
  } else {
183
- toast.info('You have been signed out remotely.');
274
+ refreshSessions();
184
275
  }
185
- try {
186
- await clearSessionStateRef.current();
187
- } catch (error) {
188
- debug.error('Failed to clear session state after session_update:', error);
276
+ } else {
277
+ // For other event types (e.g., session_created), refresh sessions
278
+ refreshSessions();
279
+
280
+ // If the current session was logged out (legacy behavior), handle it specially
281
+ if (data.sessionId === currentActiveSessionId) {
282
+ if (onRemoteSignOutRef.current) {
283
+ onRemoteSignOutRef.current();
284
+ } else {
285
+ toast.info('You have been signed out remotely.');
286
+ }
287
+ try {
288
+ await clearSessionStateRef.current();
289
+ } catch (error) {
290
+ debug.error('Failed to clear session state after session_update:', error);
291
+ }
189
292
  }
190
293
  }
191
- }
192
- };
294
+ };
193
295
 
194
- socket.on('connect', handleConnect);
195
- socket.on('session_update', handleSessionUpdate);
296
+ socket.on('connect', handleConnect);
297
+ socket.on('disconnect', handleDisconnect);
298
+ socket.on('session_update', handleSessionUpdate);
299
+
300
+ // Listen for cross-tab token updates via the Storage event.
301
+ // When another tab writes a fresh access token to localStorage,
302
+ // reconnect this tab's socket if it was disconnected.
303
+ const handleStorageEvent = (e: StorageEvent) => {
304
+ if (e.key === LS_ACCESS_TOKEN_KEY && e.newValue && socketRef.current?.disconnected) {
305
+ debug.log('Cross-tab token update detected; reconnecting socket');
306
+ authRetryCount = 0; // reset retries since we got a fresh token
307
+ socketRef.current.connect();
308
+ }
309
+ };
310
+
311
+ if (typeof window !== 'undefined') {
312
+ window.addEventListener('storage', handleStorageEvent);
313
+ // Store the handler so cleanup can remove it
314
+ (socket as any).__oxyStorageHandler = handleStorageEvent;
315
+ }
316
+ });
196
317
 
197
318
  return () => {
198
- socket.off('connect', handleConnect);
199
- socket.off('session_update', handleSessionUpdate);
200
- socket.disconnect();
201
- socketRef.current = null;
319
+ cancelled = true;
320
+ if (authRetryTimer) {
321
+ clearTimeout(authRetryTimer);
322
+ }
323
+ if (socketRef.current) {
324
+ // Remove cross-tab storage listener
325
+ if (typeof window !== 'undefined' && (socketRef.current as any).__oxyStorageHandler) {
326
+ window.removeEventListener('storage', (socketRef.current as any).__oxyStorageHandler);
327
+ }
328
+ socketRef.current.disconnect();
329
+ socketRef.current = null;
330
+ }
202
331
  };
203
332
  }, [userId, baseURL]); // Only depend on userId and baseURL - callbacks are in refs
204
333
  }
package/src/index.ts CHANGED
@@ -46,6 +46,17 @@ export {
46
46
  useAssetUsageCount,
47
47
  useIsAssetLinked,
48
48
  } from './stores/assetStore';
49
+ export {
50
+ useAccountStore,
51
+ useAccounts,
52
+ useAccountLoading,
53
+ useAccountError,
54
+ useAccountLoadingSession,
55
+ } from './stores/accountStore';
56
+ export type { QuickAccount } from './stores/accountStore';
57
+ export {
58
+ useFollowStore,
59
+ } from './stores/followStore';
49
60
 
50
61
  // --- Query Hooks ---
51
62
  export {
@@ -89,6 +100,7 @@ export type {
89
100
  } from './hooks/mutations/mutationFactory';
90
101
 
91
102
  // --- Custom Hooks ---
103
+ export { useWebSSO, isWebBrowser } from './hooks/useWebSSO';
92
104
  export { useSessionSocket } from './hooks/useSessionSocket';
93
105
  export type { UseSessionSocketOptions } from './hooks/useSessionSocket';
94
106
  export { useAssets, setOxyAssetInstance } from './hooks/useAssets';
@@ -233,7 +233,7 @@ export const useAccountStore = create<AccountState>((set, get) => ({
233
233
  get().setAccounts(ordered);
234
234
  } catch (error) {
235
235
  const errorMessage = error instanceof Error ? error.message : 'Failed to load accounts';
236
- if (__DEV__) {
236
+ if (process.env.NODE_ENV !== 'production') {
237
237
  console.error('AccountStore: Failed to load accounts:', error);
238
238
  }
239
239
  set({ error: errorMessage });
@@ -242,7 +242,7 @@ export const useAccountStore = create<AccountState>((set, get) => ({
242
242
  }
243
243
  } catch (error) {
244
244
  const errorMessage = error instanceof Error ? error.message : 'Failed to load accounts';
245
- if (__DEV__) {
245
+ if (process.env.NODE_ENV !== 'production') {
246
246
  console.error('AccountStore: Failed to load accounts:', error);
247
247
  }
248
248
  set({ error: errorMessage, loading: false });
@@ -87,7 +87,7 @@ export const fetchSessionsWithFallback = async (
87
87
  const deviceSessions = await oxyServices.getDeviceSessions(sessionId);
88
88
  return mapSessionsToClient(deviceSessions, fallbackDeviceId, fallbackUserId);
89
89
  } catch (error) {
90
- if (__DEV__ && logger) {
90
+ if (process.env.NODE_ENV !== 'production' && logger) {
91
91
  logger('Failed to get device sessions, falling back to user sessions', error);
92
92
  }
93
93
 
@@ -91,7 +91,7 @@ const createNativeStorage = async (): Promise<StorageInterface> => {
91
91
  asyncStorageInstance = asyncStorageModule.default as unknown as StorageInterface;
92
92
  return asyncStorageInstance;
93
93
  } catch (error) {
94
- if (__DEV__) {
94
+ if (process.env.NODE_ENV !== 'production') {
95
95
  console.error('Failed to import AsyncStorage:', error);
96
96
  }
97
97
  throw new Error('AsyncStorage is required in React Native environment');
package/src/global.d.ts DELETED
@@ -1 +0,0 @@
1
- declare const __DEV__: boolean;