Skip to content

Commit

Permalink
feat: introduce experimental split user and session storage
Browse files Browse the repository at this point in the history
  • Loading branch information
hf committed Jan 16, 2025
1 parent 9748dd9 commit b9b1d39
Show file tree
Hide file tree
Showing 4 changed files with 122 additions and 36 deletions.
96 changes: 89 additions & 7 deletions src/GoTrueClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@ import {
uuid,
retryable,
sleep,
supportsLocalStorage,
parseParametersFromURL,
getCodeChallengeAndMethod,
userNotAvailableProxy,
supportsLocalStorage,
} from './lib/helpers'
import { localStorageAdapter, memoryLocalStorageAdapter } from './lib/local-storage'
import { memoryLocalStorageAdapter } from './lib/local-storage'
import { polyfillGlobalThis } from './lib/polyfills'
import { version } from './lib/version'
import { LockAcquireTimeoutError, navigatorLock } from './lib/locks'
Expand Down Expand Up @@ -97,7 +98,10 @@ import type {

polyfillGlobalThis() // Make "globalThis" available

const DEFAULT_OPTIONS: Omit<Required<GoTrueClientOptions>, 'fetch' | 'storage' | 'lock'> = {
const DEFAULT_OPTIONS: Omit<
Required<GoTrueClientOptions>,
'fetch' | 'storage' | 'userStorage' | 'lock'
> = {
url: GOTRUE_URL,
storageKey: STORAGE_KEY,
autoRefreshToken: true,
Expand Down Expand Up @@ -144,6 +148,10 @@ export default class GoTrueClient {
protected autoRefreshToken: boolean
protected persistSession: boolean
protected storage: SupportedStorage
/**
* @experimental
*/
protected userStorage: SupportedStorage | null = null
protected memoryStorage: { [key: string]: string } | null = null
protected stateChangeEmitters: Map<string, Subscription> = new Map()
protected autoRefreshTicker: ReturnType<typeof setInterval> | null = null
Expand Down Expand Up @@ -236,12 +244,16 @@ export default class GoTrueClient {
this.storage = settings.storage
} else {
if (supportsLocalStorage()) {
this.storage = localStorageAdapter
this.storage = globalThis.localStorage
} else {
this.memoryStorage = {}
this.storage = memoryLocalStorageAdapter(this.memoryStorage)
}
}

if (settings.userStorage) {
this.userStorage = settings.userStorage
}
} else {
this.memoryStorage = {}
this.storage = memoryLocalStorageAdapter(this.memoryStorage)
Expand Down Expand Up @@ -1119,7 +1131,20 @@ export default class GoTrueClient {
)

if (!hasExpired) {
if (this.storage.isServer) {
if (this.userStorage) {
const maybeUser: { user?: User | null } | null = (await getItemAsync(
this.userStorage,
this.storageKey + '-user'
)) as any

if (maybeUser?.user) {
currentSession.user = maybeUser.user
} else {
currentSession.user = userNotAvailableProxy()
}
}

if (this.storage.isServer && currentSession.user) {
let suppressWarning = this.suppressGetSessionWarning
const proxySession: Session = new Proxy(currentSession, {
get: (target: any, prop: string, receiver: any) => {
Expand Down Expand Up @@ -1911,7 +1936,47 @@ export default class GoTrueClient {
this._debug(debugName, 'begin')

try {
const currentSession = await getItemAsync(this.storage, this.storageKey)
const currentSession: Session = (await getItemAsync(this.storage, this.storageKey)) as any

if (this.userStorage) {
let maybeUser: { user: User | null } | null = (await getItemAsync(
this.userStorage,
this.storageKey + '-user'
)) as any

if (!this.storage.isServer && Object.is(this.storage, this.userStorage) && !maybeUser) {
// storage and userStorage are the same storage medium, for example
// window.localStorage if userStorage does not have the user from
// storage stored, store it first thereby migrating the user object
// from storage -> userStorage

maybeUser = { user: currentSession.user }
await setItemAsync(this.userStorage, this.storageKey + '-user', maybeUser)
}

currentSession.user = maybeUser?.user ?? userNotAvailableProxy()
} else if (currentSession && !currentSession.user) {
// user storage is not set, let's check if it was previously enabled so
// we bring back the storage as it should be

if (!currentSession.user) {
// test if userStorage was previously enabled and the storage medium was the same, to move the user back under the same key
const separateUser: { user: User | null } | null = (await getItemAsync(
this.storage,
this.storageKey + '-user'
)) as any

if (separateUser && separateUser?.user) {
currentSession.user = separateUser.user

await removeItemAsync(this.storage, this.storageKey + '-user')
await setItemAsync(this.storage, this.storageKey, currentSession)
} else {
currentSession.user = userNotAvailableProxy()
}
}
}

this._debug(debugName, 'session from storage', currentSession)

if (!this._isValidSession(currentSession)) {
Expand Down Expand Up @@ -2061,13 +2126,30 @@ export default class GoTrueClient {
// _saveSession is always called whenever a new session has been acquired
// so we can safely suppress the warning returned by future getSession calls
this.suppressGetSessionWarning = true
await setItemAsync(this.storage, this.storageKey, session)

if (this.userStorage) {
await setItemAsync(this.userStorage, this.storageKey + '-user', { user: session.user })

const clone = structuredClone(session) as any // cast intentional as we're deleting the `user` property of a required type below
delete clone.user

console.log('@@@@@@@@@@@@@@@@@@@@@@@', clone)

await setItemAsync(this.storage, this.storageKey, clone)
} else {
await setItemAsync(this.storage, this.storageKey, session)
}
}

private async _removeSession() {
this._debug('#_removeSession()')

await removeItemAsync(this.storage, this.storageKey)

if (this.userStorage) {
await removeItemAsync(this.storage, this.storageKey + '-user')
}

await this._notifyAllSubscribers('SIGNED_OUT', null)
}

Expand Down
22 changes: 21 additions & 1 deletion src/lib/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { API_VERSION_HEADER_NAME } from './constants'
import { SupportedStorage } from './types'
import { SupportedStorage, User } from './types'

export function expiresAt(expiresIn: number) {
const timeNow = Math.round(Date.now() / 1000)
Expand Down Expand Up @@ -344,3 +344,23 @@ export function parseResponseAPIVersion(response: Response) {
return null
}
}

export function userNotAvailableProxy(): User {
return new Proxy({} as User, {
get: (_target: any, prop: string) => {
throw new Error(
`@supabase/auth-js: client was created with userStorage option and there was no user stored in the user storage. Accessing the "${prop}" property of the session object is not supported. Please use getUser() instead.`
)
},
set: (_target: any, prop: string) => {
throw new Error(
`@supabase/auth-js: client was created with userStorage option and there was no user stored in the user storage. Setting the "${prop}" property of the session object is not supported. Please use getUser() to fetch a user object you can manipulate.`
)
},
deleteProperty: (_target: any, prop: string) => {
throw new Error(
`@supabase/auth-js: client was created with userStorage option and there was no user stored in the user storage. Deleting the "${prop}" property of the session object is not supported. Please use getUser() to fetch a user object you can manipulate.`
)
},
})
}
28 changes: 0 additions & 28 deletions src/lib/local-storage.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,5 @@
import { supportsLocalStorage } from './helpers'
import { SupportedStorage } from './types'

/**
* Provides safe access to the globalThis.localStorage property.
*/
export const localStorageAdapter: SupportedStorage = {
getItem: (key) => {
if (!supportsLocalStorage()) {
return null
}

return globalThis.localStorage.getItem(key)
},
setItem: (key, value) => {
if (!supportsLocalStorage()) {
return
}

globalThis.localStorage.setItem(key, value)
},
removeItem: (key) => {
if (!supportsLocalStorage()) {
return
}

globalThis.localStorage.removeItem(key)
},
}

/**
* Returns a localStorage-like object that stores the key-value pairs in
* memory.
Expand Down
12 changes: 12 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ export type GoTrueClientOptions = {
persistSession?: boolean
/* Provide your own local storage implementation to use instead of the browser's local storage. */
storage?: SupportedStorage
/**
* Stores the user object in a separate storage location from the rest of the session data. When non-null, `storage` will only store a JSON object containing the access and refresh token and some adjacent metadata, while `userStorage` will only contain the user object under the key `storageKey + '-user'`.
*
* When this option is set and cookie storage is used, `getSession()` and other functions that load a session from the cookie store might not return back a user. It's very important to always use `getUser()` to fetch a user object in those scenarios.
*
* @experimental
*/
userStorage?: SupportedStorage
/* A custom fetch implementation. */
fetch?: Fetch
/* If set to 'pkce' PKCE flow. Defaults to the 'implicit' flow otherwise */
Expand Down Expand Up @@ -252,6 +260,10 @@ export interface Session {
*/
expires_at?: number
token_type: string

/**
* When using a separate user storage, accessing properties of this object will throw an error.
*/
user: User
}

Expand Down

0 comments on commit b9b1d39

Please sign in to comment.