@basictech/react 0.8.0-beta.1 → 0.8.0-beta.3
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/.turbo/turbo-build.log +10 -10
- package/changelog.md +12 -0
- package/dist/index.d.mts +59 -43
- package/dist/index.d.ts +59 -43
- package/dist/index.js +1015 -200
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1006 -193
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -1
- package/readme.md +33 -0
- package/src/AuthContext.tsx +157 -177
- package/src/context.tsx +104 -0
- package/src/core/auth/AuthManager.ts +64 -40
- package/src/dev/BasicDevToolbar.tsx +665 -0
- package/src/index.ts +3 -2
- package/src/sync/syncProtocol.js +30 -0
- package/src/utils/network.ts +69 -16
package/src/context.tsx
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
2
|
+
import type { BasicDB } from './core/db'
|
|
3
|
+
import type { User, AuthResult, GetTokenOptions } from './core/auth/AuthManager'
|
|
4
|
+
import type { DBMode } from './core/db'
|
|
5
|
+
|
|
6
|
+
export enum DBStatus {
|
|
7
|
+
LOADING = 'LOADING',
|
|
8
|
+
OFFLINE = 'OFFLINE',
|
|
9
|
+
CONNECTING = 'CONNECTING',
|
|
10
|
+
ONLINE = 'ONLINE',
|
|
11
|
+
SYNCING = 'SYNCING',
|
|
12
|
+
ERROR = 'ERROR',
|
|
13
|
+
ERROR_WILL_RETRY = 'ERROR_WILL_RETRY',
|
|
14
|
+
ERROR_TOKEN_EXPIRED = 'ERROR_TOKEN_EXPIRED',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Snapshot of local schema vs server (for dev toolbar and debugging). */
|
|
18
|
+
export type BasicSchemaDevInfo = {
|
|
19
|
+
projectId: string | null
|
|
20
|
+
localVersion: number | undefined
|
|
21
|
+
status: string
|
|
22
|
+
valid: boolean
|
|
23
|
+
lastCheckedAt: number
|
|
24
|
+
error?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Context type for useBasic hook
|
|
29
|
+
*/
|
|
30
|
+
export type BasicContextType = {
|
|
31
|
+
isReady: boolean
|
|
32
|
+
isSignedIn: boolean
|
|
33
|
+
user: User | null
|
|
34
|
+
did: string | null
|
|
35
|
+
scope: string | null
|
|
36
|
+
hasScope: (scope: string) => boolean
|
|
37
|
+
missingScopes: () => string[]
|
|
38
|
+
|
|
39
|
+
signIn: () => Promise<void>
|
|
40
|
+
signInWithHandle: (handle: string) => Promise<void>
|
|
41
|
+
signOut: () => Promise<void>
|
|
42
|
+
signInWithCode: (code: string, state?: string) => Promise<AuthResult>
|
|
43
|
+
|
|
44
|
+
getToken: (options?: GetTokenOptions) => Promise<string>
|
|
45
|
+
getSignInUrl: (redirectUri?: string) => Promise<string>
|
|
46
|
+
|
|
47
|
+
db: BasicDB
|
|
48
|
+
dbStatus: DBStatus
|
|
49
|
+
dbMode: DBMode
|
|
50
|
+
|
|
51
|
+
/** Local schema vs server status; null if no schema on the provider. */
|
|
52
|
+
devInfo: BasicSchemaDevInfo | null
|
|
53
|
+
/** Re-run remote schema check (dev toolbar). */
|
|
54
|
+
refreshSchemaStatus: () => Promise<void>
|
|
55
|
+
|
|
56
|
+
isAuthReady: boolean
|
|
57
|
+
signin: () => Promise<void>
|
|
58
|
+
signout: () => Promise<void>
|
|
59
|
+
signinWithCode: (code: string, state?: string) => Promise<AuthResult>
|
|
60
|
+
getSignInLink: (redirectUri?: string) => Promise<string>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const noDb: BasicDB = {
|
|
64
|
+
collection: () => {
|
|
65
|
+
throw new Error('no basicdb found - initialization failed. double check your schema.')
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const BasicContext = createContext<BasicContextType>({
|
|
70
|
+
isReady: false,
|
|
71
|
+
isSignedIn: false,
|
|
72
|
+
user: null,
|
|
73
|
+
did: null,
|
|
74
|
+
scope: null,
|
|
75
|
+
hasScope: () => false,
|
|
76
|
+
missingScopes: () => [],
|
|
77
|
+
|
|
78
|
+
signIn: () => Promise.resolve(),
|
|
79
|
+
signInWithHandle: () => Promise.resolve(),
|
|
80
|
+
signOut: () => Promise.resolve(),
|
|
81
|
+
signInWithCode: () => Promise.resolve({ success: false }),
|
|
82
|
+
|
|
83
|
+
getToken: (_options?: GetTokenOptions) => Promise.reject(new Error('no token')),
|
|
84
|
+
getSignInUrl: () => Promise.resolve(''),
|
|
85
|
+
|
|
86
|
+
db: noDb,
|
|
87
|
+
dbStatus: DBStatus.LOADING,
|
|
88
|
+
dbMode: 'sync',
|
|
89
|
+
|
|
90
|
+
devInfo: null,
|
|
91
|
+
refreshSchemaStatus: async () => {},
|
|
92
|
+
|
|
93
|
+
isAuthReady: false,
|
|
94
|
+
signin: () => Promise.resolve(),
|
|
95
|
+
signout: () => Promise.resolve(),
|
|
96
|
+
signinWithCode: () => Promise.resolve({ success: false }),
|
|
97
|
+
getSignInLink: () => Promise.resolve(''),
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
export function useBasic() {
|
|
101
|
+
return useContext(BasicContext)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export { noDb }
|
|
@@ -223,8 +223,11 @@ export class AuthManager {
|
|
|
223
223
|
const refreshToken = await this.storage.get(STORAGE_KEYS.REFRESH_TOKEN)
|
|
224
224
|
if (refreshToken) {
|
|
225
225
|
log('Found refresh token in storage, attempting to refresh access token')
|
|
226
|
-
this.exchangeToken(refreshToken, true).catch((error) => {
|
|
226
|
+
this.exchangeToken(refreshToken, true).catch(async (error) => {
|
|
227
227
|
log('Error fetching refresh token:', error)
|
|
228
|
+
if (this.isNetworkError(error)) {
|
|
229
|
+
await this.restoreCachedUser()
|
|
230
|
+
}
|
|
228
231
|
})
|
|
229
232
|
} else {
|
|
230
233
|
const cachedUserInfo = await this.storage.get(STORAGE_KEYS.USER_INFO)
|
|
@@ -300,7 +303,8 @@ export class AuthManager {
|
|
|
300
303
|
log('Token refresh already in progress, waiting...')
|
|
301
304
|
try {
|
|
302
305
|
const newToken = await this.refreshPromise
|
|
303
|
-
|
|
306
|
+
if (!newToken?.access_token) throw new Error('Token refresh returned empty access token')
|
|
307
|
+
return newToken.access_token
|
|
304
308
|
} catch (error) {
|
|
305
309
|
log('In-flight refresh failed:', error)
|
|
306
310
|
if (this.isNetworkError(error)) {
|
|
@@ -315,7 +319,8 @@ export class AuthManager {
|
|
|
315
319
|
if (refreshToken) {
|
|
316
320
|
try {
|
|
317
321
|
const newToken = await this.exchangeToken(refreshToken, true)
|
|
318
|
-
|
|
322
|
+
if (!newToken?.access_token) throw new Error('Token refresh returned empty access token')
|
|
323
|
+
return newToken.access_token
|
|
319
324
|
} catch (error) {
|
|
320
325
|
log('Failed to refresh expired token:', error)
|
|
321
326
|
if (this.isNetworkError(error)) {
|
|
@@ -329,7 +334,8 @@ export class AuthManager {
|
|
|
329
334
|
}
|
|
330
335
|
}
|
|
331
336
|
|
|
332
|
-
|
|
337
|
+
if (!this.token.access_token) throw new Error('Token exists but access_token is empty')
|
|
338
|
+
return this.token.access_token
|
|
333
339
|
}
|
|
334
340
|
|
|
335
341
|
async getSignInUrl(redirectUri?: string, endpoints?: PdsEndpoints): Promise<string> {
|
|
@@ -342,7 +348,7 @@ export class AuthManager {
|
|
|
342
348
|
const pdsEndpoints = endpoints || this.defaultPdsEndpoints()
|
|
343
349
|
await this.storage.set(STORAGE_KEYS.PDS_ENDPOINTS, JSON.stringify(pdsEndpoints))
|
|
344
350
|
|
|
345
|
-
const randomState =
|
|
351
|
+
const randomState = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)))
|
|
346
352
|
await this.storage.set(STORAGE_KEYS.AUTH_STATE, randomState)
|
|
347
353
|
|
|
348
354
|
const redirectUrl = redirectUri || window.location.href
|
|
@@ -486,7 +492,10 @@ export class AuthManager {
|
|
|
486
492
|
}
|
|
487
493
|
|
|
488
494
|
/**
|
|
489
|
-
* Register online/offline handlers that retry pending
|
|
495
|
+
* Register online/offline and visibility handlers that retry pending
|
|
496
|
+
* refreshes and proactively refresh tokens when the app resumes from
|
|
497
|
+
* background (critical for PWAs and mobile browsers where timers are
|
|
498
|
+
* frozen while backgrounded).
|
|
490
499
|
* Returns a cleanup function for useEffect teardown.
|
|
491
500
|
*/
|
|
492
501
|
setupNetworkListeners(): () => void {
|
|
@@ -510,12 +519,28 @@ export class AuthManager {
|
|
|
510
519
|
this.isOnline = false
|
|
511
520
|
}
|
|
512
521
|
|
|
522
|
+
const handleVisibilityChange = () => {
|
|
523
|
+
if (document.visibilityState === 'visible' && this.isSignedIn) {
|
|
524
|
+
log('App became visible - checking token freshness')
|
|
525
|
+
this.getToken().catch(err => {
|
|
526
|
+
log('Token refresh on visibility resume failed:', err)
|
|
527
|
+
})
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
513
531
|
window.addEventListener('online', handleOnline)
|
|
514
532
|
window.addEventListener('offline', handleOffline)
|
|
515
533
|
|
|
534
|
+
if (typeof document !== 'undefined') {
|
|
535
|
+
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
536
|
+
}
|
|
537
|
+
|
|
516
538
|
return () => {
|
|
517
539
|
window.removeEventListener('online', handleOnline)
|
|
518
540
|
window.removeEventListener('offline', handleOffline)
|
|
541
|
+
if (typeof document !== 'undefined') {
|
|
542
|
+
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
543
|
+
}
|
|
519
544
|
}
|
|
520
545
|
}
|
|
521
546
|
|
|
@@ -581,34 +606,7 @@ export class AuthManager {
|
|
|
581
606
|
if (decoded.sub) this.did = decoded.sub
|
|
582
607
|
if (decoded.scope) this.tokenScope = decoded.scope
|
|
583
608
|
|
|
584
|
-
|
|
585
|
-
const isExpired = decoded.exp && decoded.exp < (Date.now() / 1000) + expirationBuffer
|
|
586
|
-
|
|
587
|
-
if (isExpired) {
|
|
588
|
-
log('token is expired - refreshing ...')
|
|
589
|
-
const refreshToken = this.token.refresh_token
|
|
590
|
-
if (!refreshToken) {
|
|
591
|
-
log('Error: No refresh token available for expired token')
|
|
592
|
-
this.isAuthReady = true
|
|
593
|
-
this.notify()
|
|
594
|
-
return
|
|
595
|
-
}
|
|
596
|
-
try {
|
|
597
|
-
const newToken = await this.exchangeToken(refreshToken, true)
|
|
598
|
-
await this.fetchUser(newToken?.access_token || '')
|
|
599
|
-
} catch (error) {
|
|
600
|
-
log('Failed to refresh token in processNewToken:', error)
|
|
601
|
-
if (this.isNetworkError(error)) {
|
|
602
|
-
log('Network issue - continuing with expired token until online')
|
|
603
|
-
await this.fetchUser(this.token.access_token)
|
|
604
|
-
} else {
|
|
605
|
-
this.isAuthReady = true
|
|
606
|
-
this.notify()
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
} else {
|
|
610
|
-
await this.fetchUser(this.token.access_token)
|
|
611
|
-
}
|
|
609
|
+
await this.fetchUser(this.token.access_token)
|
|
612
610
|
} catch (error) {
|
|
613
611
|
log('Error processing token:', error)
|
|
614
612
|
this.isAuthReady = true
|
|
@@ -616,6 +614,19 @@ export class AuthManager {
|
|
|
616
614
|
}
|
|
617
615
|
}
|
|
618
616
|
|
|
617
|
+
private async restoreCachedUser(): Promise<void> {
|
|
618
|
+
const cached = await this.storage.get(STORAGE_KEYS.USER_INFO)
|
|
619
|
+
if (cached) {
|
|
620
|
+
try {
|
|
621
|
+
this.user = JSON.parse(cached)
|
|
622
|
+
this.isSignedIn = true
|
|
623
|
+
log('Restored cached user info for offline mode')
|
|
624
|
+
} catch { /* corrupted cache, ignore */ }
|
|
625
|
+
}
|
|
626
|
+
this.isAuthReady = true
|
|
627
|
+
this.notify()
|
|
628
|
+
}
|
|
629
|
+
|
|
619
630
|
private async fetchUser(accessToken: string): Promise<void> {
|
|
620
631
|
log('fetching user')
|
|
621
632
|
try {
|
|
@@ -657,8 +668,12 @@ export class AuthManager {
|
|
|
657
668
|
this.notify()
|
|
658
669
|
} catch (error) {
|
|
659
670
|
log('Failed to fetch user info:', error)
|
|
660
|
-
this.
|
|
661
|
-
|
|
671
|
+
if (this.isNetworkError(error)) {
|
|
672
|
+
await this.restoreCachedUser()
|
|
673
|
+
} else {
|
|
674
|
+
this.isAuthReady = true
|
|
675
|
+
this.notify()
|
|
676
|
+
}
|
|
662
677
|
}
|
|
663
678
|
}
|
|
664
679
|
|
|
@@ -772,9 +787,15 @@ export class AuthManager {
|
|
|
772
787
|
throw new Error('Network issue - refresh will be retried when online')
|
|
773
788
|
}
|
|
774
789
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
790
|
+
// Only clear stored auth on definitive OAuth rejection.
|
|
791
|
+
// Transient server errors (500, 503, etc.) should NOT wipe
|
|
792
|
+
// the refresh token — the user can retry later.
|
|
793
|
+
const definitiveErrors = ['invalid_grant', 'invalid_client', 'unauthorized_client']
|
|
794
|
+
if (typeof token.error === 'string' && definitiveErrors.includes(token.error)) {
|
|
795
|
+
await this.clearStoredAuth()
|
|
796
|
+
this.resetAuthState()
|
|
797
|
+
this.notify()
|
|
798
|
+
}
|
|
778
799
|
throw new Error(`Token refresh failed: ${token.error}`)
|
|
779
800
|
} else {
|
|
780
801
|
this.token = token
|
|
@@ -800,7 +821,9 @@ export class AuthManager {
|
|
|
800
821
|
} catch (error) {
|
|
801
822
|
log('Token refresh error:', error)
|
|
802
823
|
|
|
803
|
-
|
|
824
|
+
const msg = error instanceof Error ? error.message : ''
|
|
825
|
+
const alreadyHandled = msg.startsWith('Token refresh failed:')
|
|
826
|
+
if (!alreadyHandled && !this.isNetworkError(error)) {
|
|
804
827
|
await this.clearStoredAuth()
|
|
805
828
|
this.resetAuthState()
|
|
806
829
|
this.notify()
|
|
@@ -850,6 +873,7 @@ export class AuthManager {
|
|
|
850
873
|
}
|
|
851
874
|
|
|
852
875
|
private isNetworkError(error: unknown): boolean {
|
|
876
|
+
if (error instanceof TypeError) return true
|
|
853
877
|
if (error instanceof Error) {
|
|
854
878
|
return error.message.includes('offline') || error.message.includes('Network')
|
|
855
879
|
}
|