@oxyhq/auth 2.0.3 → 2.0.5

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.
Files changed (42) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/WebOxyProvider.js +37 -0
  3. package/dist/cjs/hooks/mutations/useAccountMutations.js +185 -43
  4. package/dist/cjs/hooks/queryClient.js +136 -92
  5. package/dist/cjs/hooks/useFileDownloadUrl.js +12 -36
  6. package/dist/cjs/hooks/useSessionSocket.js +250 -115
  7. package/dist/cjs/index.js +13 -3
  8. package/dist/cjs/stores/accountStore.js +2 -2
  9. package/dist/cjs/utils/sessionHelpers.js +4 -2
  10. package/dist/cjs/utils/storageHelpers.js +37 -11
  11. package/dist/esm/.tsbuildinfo +1 -1
  12. package/dist/esm/WebOxyProvider.js +38 -1
  13. package/dist/esm/hooks/mutations/useAccountMutations.js +186 -44
  14. package/dist/esm/hooks/queryClient.js +132 -89
  15. package/dist/esm/hooks/useFileDownloadUrl.js +11 -34
  16. package/dist/esm/hooks/useSessionSocket.js +217 -112
  17. package/dist/esm/index.js +4 -1
  18. package/dist/esm/stores/accountStore.js +2 -2
  19. package/dist/esm/utils/sessionHelpers.js +4 -2
  20. package/dist/esm/utils/storageHelpers.js +37 -11
  21. package/dist/types/.tsbuildinfo +1 -1
  22. package/dist/types/WebOxyProvider.d.ts +1 -1
  23. package/dist/types/hooks/mutations/useAccountMutations.d.ts +153 -9
  24. package/dist/types/hooks/queries/useAccountQueries.d.ts +11 -7
  25. package/dist/types/hooks/queries/useSecurityQueries.d.ts +2 -2
  26. package/dist/types/hooks/queries/useServicesQueries.d.ts +7 -5
  27. package/dist/types/hooks/queryClient.d.ts +24 -10
  28. package/dist/types/hooks/useAssets.d.ts +1 -1
  29. package/dist/types/hooks/useFileDownloadUrl.d.ts +2 -6
  30. package/dist/types/index.d.ts +5 -1
  31. package/dist/types/utils/sessionHelpers.d.ts +3 -1
  32. package/package.json +29 -5
  33. package/src/WebOxyProvider.tsx +39 -1
  34. package/src/hooks/mutations/useAccountMutations.ts +230 -57
  35. package/src/hooks/queryClient.ts +140 -83
  36. package/src/hooks/useFileDownloadUrl.ts +15 -39
  37. package/src/hooks/useSessionSocket.ts +273 -112
  38. package/src/index.ts +13 -1
  39. package/src/stores/accountStore.ts +2 -2
  40. package/src/utils/sessionHelpers.ts +4 -2
  41. package/src/utils/storageHelpers.ts +50 -11
  42. package/src/global.d.ts +0 -1
@@ -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,80 @@ 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 (err) {
31
+ console.warn('[oxy.session-socket] localStorage read failed:', err);
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Minimal subset of the socket.io-client Socket API used by this hook.
38
+ * We avoid importing socket.io-client types directly because the package
39
+ * is an optional peer dependency.
40
+ *
41
+ * `on()` uses a generic per-call handler signature because each socket event
42
+ * carries its own payload shape.
43
+ */
44
+ interface MinimalSocket {
45
+ id?: string;
46
+ disconnected: boolean;
47
+ connect: () => void;
48
+ disconnect: () => void;
49
+ on<Args extends unknown[] = unknown[]>(
50
+ event: string,
51
+ handler: (...args: Args) => void
52
+ ): void;
53
+ }
54
+
55
+ /**
56
+ * Socket extended with a private property used to track the cross-tab
57
+ * storage event listener so cleanup can remove it.
58
+ */
59
+ interface SocketWithStorageHandler extends MinimalSocket {
60
+ __oxyStorageHandler?: (event: StorageEvent) => void;
61
+ }
62
+
63
+ type SocketIOFactory = (uri: string, opts?: Record<string, unknown>) => MinimalSocket;
64
+
65
+ let _io: SocketIOFactory | null = null;
66
+ let _ioLoadAttempted = false;
67
+
68
+ async function getSocketIO(): Promise<SocketIOFactory | null> {
69
+ if (_io) return _io;
70
+ if (_ioLoadAttempted) return null;
71
+ _ioLoadAttempted = true;
72
+ try {
73
+ const mod = (await import('socket.io-client')) as {
74
+ io?: SocketIOFactory;
75
+ default?: SocketIOFactory;
76
+ };
77
+ _io = mod.io ?? mod.default ?? null;
78
+ return _io;
79
+ } catch (err) {
80
+ console.warn('[oxy.session-socket] socket.io-client import failed:', err);
81
+ debug.warn('socket.io-client is not installed. useSessionSocket will be disabled. Install it with: bun add socket.io-client');
82
+ return null;
83
+ }
84
+ }
85
+
13
86
  export interface UseSessionSocketOptions {
14
87
  onRemoteSignOut?: () => void;
15
88
  onSessionRemoved?: (sessionId: string) => void;
@@ -30,7 +103,7 @@ export function useSessionSocket(options?: UseSessionSocketOptions) {
30
103
  return active?.deviceId ?? null;
31
104
  }, [sessions, activeSessionId]);
32
105
 
33
- const socketRef = useRef<any>(null);
106
+ const socketRef = useRef<SocketWithStorageHandler | null>(null);
34
107
 
35
108
  // Store callbacks and values in refs to avoid reconnecting when they change
36
109
  const clearSessionStateRef = useRef(clearSessionState);
@@ -66,139 +139,227 @@ export function useSessionSocket(options?: UseSessionSocketOptions) {
66
139
  socketRef.current = null;
67
140
  }
68
141
 
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;
78
-
79
- // Server auto-joins the user to `user:<userId>` room on connection
80
- const handleConnect = () => {
81
- debug.log('Socket connected:', socket.id);
82
- };
142
+ let cancelled = false;
143
+ let authRetryCount = 0;
144
+ let authRetryTimer: ReturnType<typeof setTimeout> | null = null;
83
145
 
84
- const refreshSessions = () => {
85
- invalidateSessionQueries(queryClientRef.current);
86
- return Promise.resolve();
146
+ /**
147
+ * Resolve the best available access token.
148
+ * Prefers the in-memory token from OxyServices; falls back to
149
+ * localStorage which may have been updated by another tab.
150
+ */
151
+ const resolveToken = (): string | null => {
152
+ return oxyServices.getAccessToken() || readTokenFromStorage();
87
153
  };
88
154
 
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
- }
155
+ getSocketIO().then((ioFn) => {
156
+ if (cancelled || !ioFn) return;
106
157
 
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' });
158
+ // Connect with auth token; use callback so reconnections get a fresh token.
159
+ // If no token is available at all, we skip the initial connect and let
160
+ // the storage listener or retry logic connect when a token appears.
161
+ const token = resolveToken();
162
+ const socket: SocketWithStorageHandler = ioFn(baseURL, {
163
+ transports: ['websocket'],
164
+ autoConnect: !!token, // don't auto-connect when there is no token
165
+ auth: (cb: (data: { token: string }) => void) => {
166
+ const resolved = resolveToken();
167
+ if (!resolved) {
168
+ // No token available -- disconnect gracefully instead of sending
169
+ // an empty string that the server will reject.
170
+ debug.warn('No access token available for socket auth; disconnecting.');
171
+ if (socketRef.current) {
172
+ socketRef.current.disconnect();
119
173
  }
174
+ return;
120
175
  }
176
+ cb({ token: resolved });
177
+ },
178
+ });
179
+ socketRef.current = socket;
180
+
181
+ // Server auto-joins the user to `user:<userId>` room on connection
182
+ const handleConnect = () => {
183
+ debug.log('Socket connected:', socket.id);
184
+ // Successful connection resets the auth retry counter.
185
+ authRetryCount = 0;
186
+ };
187
+
188
+ /**
189
+ * Handle socket disconnection. When the disconnect reason indicates an
190
+ * auth failure (server rejected the token), schedule a short retry so
191
+ * that an in-progress token refresh can complete before the next attempt.
192
+ */
193
+ const handleDisconnect = (reason: string) => {
194
+ debug.log('Socket disconnected:', reason);
195
+ // "io server disconnect" = server forcibly closed the connection (auth failure).
196
+ // "transport error" can also happen when the auth callback aborted.
197
+ if (
198
+ (reason === 'io server disconnect' || reason === 'transport error') &&
199
+ authRetryCount < MAX_AUTH_RETRIES &&
200
+ !cancelled
201
+ ) {
202
+ authRetryCount++;
203
+ debug.log(
204
+ `Auth-related disconnect; scheduling retry ${authRetryCount}/${MAX_AUTH_RETRIES} in ${AUTH_RETRY_DELAY_MS}ms`
205
+ );
206
+ authRetryTimer = setTimeout(() => {
207
+ if (cancelled) return;
208
+ const retryToken = resolveToken();
209
+ if (retryToken && socketRef.current) {
210
+ debug.log('Retrying socket connection with refreshed token');
211
+ socketRef.current.connect();
212
+ }
213
+ }, AUTH_RETRY_DELAY_MS);
214
+ }
215
+ };
216
+
217
+ const refreshSessions = () => {
218
+ invalidateSessionQueries(queryClientRef.current);
219
+ return Promise.resolve();
220
+ };
221
+
222
+ const triggerLocalSignOut = async (toastMessage: string, errorContext: string) => {
223
+ if (onRemoteSignOutRef.current) {
224
+ onRemoteSignOutRef.current();
121
225
  } else {
122
- refreshSessions();
226
+ toast.info(toastMessage);
123
227
  }
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);
228
+ // Clear local state since the server has already removed the session.
229
+ // Await so storage cleanup completes before any subsequent navigation.
230
+ try {
231
+ await clearSessionStateRef.current();
232
+ } catch (error) {
233
+ if (process.env.NODE_ENV !== 'production') {
234
+ logger.error(
235
+ `Failed to clear session state after ${errorContext}`,
236
+ error instanceof Error ? error : new Error(String(error)),
237
+ { component: 'useSessionSocket' },
238
+ );
129
239
  }
130
240
  }
241
+ };
131
242
 
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();
136
- } else {
137
- toast.info('This device has been removed. You have been signed out.');
243
+ const handleSessionUpdate = async (data: {
244
+ type: string;
245
+ sessionId?: string;
246
+ deviceId?: string;
247
+ sessionIds?: string[]
248
+ }) => {
249
+ debug.log('Received session_update:', data);
250
+
251
+ const currentActiveSessionId = activeSessionIdRef.current;
252
+ const deviceId = currentDeviceIdRef.current;
253
+
254
+ // Strict whitelist. Every event type that may sign the user out must
255
+ // appear in the switch. Anything unknown falls through to `default`,
256
+ // which only logs in dev. This guards against future server-side event
257
+ // additions (e.g. `session_created` after a successful sign-in)
258
+ // accidentally triggering sign-out via a fallback branch that compares
259
+ // `data.sessionId === currentActiveSessionId` — that branch would match
260
+ // the user's NEW session id and trigger an instant remote sign-out
261
+ // toast on every login.
262
+ switch (data.type) {
263
+ case 'session_removed': {
264
+ if (data.sessionId && onSessionRemovedRef.current) {
265
+ onSessionRemovedRef.current(data.sessionId);
266
+ }
267
+ if (data.sessionId && data.sessionId === currentActiveSessionId) {
268
+ await triggerLocalSignOut('You have been signed out remotely.', 'session_removed');
269
+ } else {
270
+ refreshSessions();
271
+ }
272
+ break;
138
273
  }
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' });
274
+ case 'device_removed': {
275
+ if (data.sessionIds && onSessionRemovedRef.current) {
276
+ for (const sessionId of data.sessionIds) {
277
+ onSessionRemovedRef.current(sessionId);
278
+ }
144
279
  }
280
+ if (data.deviceId && deviceId && data.deviceId === deviceId) {
281
+ await triggerLocalSignOut(
282
+ 'This device has been removed. You have been signed out.',
283
+ 'device_removed',
284
+ );
285
+ } else {
286
+ refreshSessions();
287
+ }
288
+ break;
145
289
  }
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);
290
+ case 'sessions_removed': {
291
+ if (data.sessionIds && onSessionRemovedRef.current) {
292
+ for (const sessionId of data.sessionIds) {
293
+ onSessionRemovedRef.current(sessionId);
294
+ }
295
+ }
296
+ if (
297
+ data.sessionIds &&
298
+ currentActiveSessionId &&
299
+ data.sessionIds.includes(currentActiveSessionId)
300
+ ) {
301
+ await triggerLocalSignOut('You have been signed out remotely.', 'sessions_removed');
302
+ } else {
303
+ refreshSessions();
304
+ }
305
+ break;
154
306
  }
155
- }
156
-
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();
161
- } else {
162
- toast.info('You have been signed out remotely.');
307
+ case 'session_created':
308
+ case 'session_update': {
309
+ // Lifecycle event for the current user. Just resync the sessions
310
+ // list never sign out.
311
+ refreshSessions();
312
+ break;
163
313
  }
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' });
314
+ default: {
315
+ if (process.env.NODE_ENV !== 'production') {
316
+ logger.warn('Unknown session socket event type', {
317
+ component: 'useSessionSocket',
318
+ type: data.type,
319
+ });
169
320
  }
321
+ break;
170
322
  }
171
- } else {
172
- refreshSessions();
173
323
  }
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();
182
- } else {
183
- toast.info('You have been signed out remotely.');
184
- }
185
- try {
186
- await clearSessionStateRef.current();
187
- } catch (error) {
188
- debug.error('Failed to clear session state after session_update:', error);
189
- }
324
+ };
325
+
326
+ socket.on('connect', handleConnect);
327
+ socket.on('disconnect', handleDisconnect);
328
+ socket.on('session_update', handleSessionUpdate);
329
+
330
+ // Listen for cross-tab token updates via the Storage event.
331
+ // When another tab writes a fresh access token to localStorage,
332
+ // reconnect this tab's socket if it was disconnected.
333
+ const handleStorageEvent = (e: StorageEvent) => {
334
+ if (e.key === LS_ACCESS_TOKEN_KEY && e.newValue && socketRef.current?.disconnected) {
335
+ debug.log('Cross-tab token update detected; reconnecting socket');
336
+ authRetryCount = 0; // reset retries since we got a fresh token
337
+ socketRef.current.connect();
190
338
  }
191
- }
192
- };
339
+ };
193
340
 
194
- socket.on('connect', handleConnect);
195
- socket.on('session_update', handleSessionUpdate);
341
+ if (typeof window !== 'undefined') {
342
+ window.addEventListener('storage', handleStorageEvent);
343
+ // Store the handler so cleanup can remove it
344
+ socket.__oxyStorageHandler = handleStorageEvent;
345
+ }
346
+ });
196
347
 
197
348
  return () => {
198
- socket.off('connect', handleConnect);
199
- socket.off('session_update', handleSessionUpdate);
200
- socket.disconnect();
201
- socketRef.current = null;
349
+ cancelled = true;
350
+ if (authRetryTimer) {
351
+ clearTimeout(authRetryTimer);
352
+ }
353
+ const currentSocket = socketRef.current;
354
+ if (currentSocket) {
355
+ // Remove cross-tab storage listener
356
+ const storageHandler = currentSocket.__oxyStorageHandler;
357
+ if (typeof window !== 'undefined' && storageHandler) {
358
+ window.removeEventListener('storage', storageHandler);
359
+ }
360
+ currentSocket.disconnect();
361
+ socketRef.current = null;
362
+ }
202
363
  };
203
364
  }, [userId, baseURL]); // Only depend on userId and baseURL - callbacks are in refs
204
365
  }
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,10 +100,11 @@ 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';
95
- export { useFileDownloadUrl, setOxyFileUrlInstance } from './hooks/useFileDownloadUrl';
107
+ export { useFileDownloadUrl } from './hooks/useFileDownloadUrl';
96
108
  export { useFollow, useFollowerCounts } from './hooks/useFollow';
97
109
  export { useFileFiltering } from './hooks/useFileFiltering';
98
110
  export type { ViewMode, SortBy, SortOrder } from './hooks/useFileFiltering';
@@ -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 });
@@ -68,7 +68,9 @@ export const mapSessionsToClient = (
68
68
  };
69
69
 
70
70
  /**
71
- * Fetch device sessions with fallback to the legacy session endpoint when needed.
71
+ * Fetch device sessions, falling back to the per-user session endpoint
72
+ * if the device endpoint is unavailable (older API versions or disabled
73
+ * device-grouping feature flag).
72
74
  *
73
75
  * @param oxyServices - Oxy service instance
74
76
  * @param sessionId - Session identifier to fetch
@@ -87,7 +89,7 @@ export const fetchSessionsWithFallback = async (
87
89
  const deviceSessions = await oxyServices.getDeviceSessions(sessionId);
88
90
  return mapSessionsToClient(deviceSessions, fallbackDeviceId, fallbackUserId);
89
91
  } catch (error) {
90
- if (__DEV__ && logger) {
92
+ if (process.env.NODE_ENV !== 'production' && logger) {
91
93
  logger('Failed to get device sessions, falling back to user sessions', error);
92
94
  }
93
95
 
@@ -19,7 +19,8 @@ const MEMORY_STORAGE = (): StorageInterface => {
19
19
 
20
20
  return {
21
21
  async getItem(key: string) {
22
- return store.has(key) ? store.get(key)! : null;
22
+ const value = store.get(key);
23
+ return value === undefined ? null : value;
23
24
  },
24
25
  async setItem(key: string, value: string) {
25
26
  store.set(key, value);
@@ -46,29 +47,33 @@ const createWebStorage = (): StorageInterface => {
46
47
  async getItem(key: string) {
47
48
  try {
48
49
  return window.localStorage.getItem(key);
49
- } catch {
50
+ } catch (err) {
51
+ console.warn('[oxy.storage] localStorage.getItem failed:', err);
50
52
  return null;
51
53
  }
52
54
  },
53
55
  async setItem(key: string, value: string) {
54
56
  try {
55
57
  window.localStorage.setItem(key, value);
56
- } catch {
57
- // Ignore quota or access issues for now.
58
+ } catch (err) {
59
+ // Quota exceeded or storage disabled (e.g., Safari private mode).
60
+ // Surface to logs so it is debuggable, but do not throw so callers
61
+ // can keep functioning with degraded persistence.
62
+ console.warn('[oxy.storage] localStorage.setItem failed:', err);
58
63
  }
59
64
  },
60
65
  async removeItem(key: string) {
61
66
  try {
62
67
  window.localStorage.removeItem(key);
63
- } catch {
64
- // Ignore failures.
68
+ } catch (err) {
69
+ console.warn('[oxy.storage] localStorage.removeItem failed:', err);
65
70
  }
66
71
  },
67
72
  async clear() {
68
73
  try {
69
74
  window.localStorage.clear();
70
- } catch {
71
- // Ignore failures.
75
+ } catch (err) {
76
+ console.warn('[oxy.storage] localStorage.clear failed:', err);
72
77
  }
73
78
  },
74
79
  };
@@ -76,6 +81,31 @@ const createWebStorage = (): StorageInterface => {
76
81
 
77
82
  let asyncStorageInstance: StorageInterface | null = null;
78
83
 
84
+ /**
85
+ * Structural type for the React Native AsyncStorage default export.
86
+ * Only includes the methods this SDK uses.
87
+ */
88
+ interface AsyncStorageLike {
89
+ getItem: (key: string) => Promise<string | null>;
90
+ setItem: (key: string, value: string) => Promise<void>;
91
+ removeItem: (key: string) => Promise<void>;
92
+ clear: () => Promise<void>;
93
+ }
94
+
95
+ /**
96
+ * Type guard verifying that an imported value exposes the AsyncStorage API.
97
+ */
98
+ const isAsyncStorageLike = (value: unknown): value is AsyncStorageLike => {
99
+ if (typeof value !== 'object' || value === null) return false;
100
+ const candidate = value as Record<string, unknown>;
101
+ return (
102
+ typeof candidate.getItem === 'function' &&
103
+ typeof candidate.setItem === 'function' &&
104
+ typeof candidate.removeItem === 'function' &&
105
+ typeof candidate.clear === 'function'
106
+ );
107
+ };
108
+
79
109
  /**
80
110
  * Lazily import React Native AsyncStorage implementation.
81
111
  */
@@ -87,11 +117,20 @@ const createNativeStorage = async (): Promise<StorageInterface> => {
87
117
  try {
88
118
  // Variable indirection prevents bundlers (Vite, webpack) from statically resolving this
89
119
  const moduleName = '@react-native-async-storage/async-storage';
90
- const asyncStorageModule = await import(moduleName);
91
- asyncStorageInstance = asyncStorageModule.default as unknown as StorageInterface;
120
+ const asyncStorageModule = (await import(moduleName)) as { default?: unknown };
121
+ const candidate = asyncStorageModule.default;
122
+ if (!isAsyncStorageLike(candidate)) {
123
+ throw new Error('AsyncStorage default export does not match expected API');
124
+ }
125
+ asyncStorageInstance = {
126
+ getItem: (key) => candidate.getItem(key),
127
+ setItem: (key, value) => candidate.setItem(key, value),
128
+ removeItem: (key) => candidate.removeItem(key),
129
+ clear: () => candidate.clear(),
130
+ };
92
131
  return asyncStorageInstance;
93
132
  } catch (error) {
94
- if (__DEV__) {
133
+ if (process.env.NODE_ENV !== 'production') {
95
134
  console.error('Failed to import AsyncStorage:', error);
96
135
  }
97
136
  throw new Error('AsyncStorage is required in React Native environment');
package/src/global.d.ts DELETED
@@ -1 +0,0 @@
1
- declare const __DEV__: boolean;