Skip to content

Commit

Permalink
feat: add _acquireLock and navigatorLock (#736)
Browse files Browse the repository at this point in the history
Built on top of: #734 

Adds an experimental `lock` option. This is used internally within
`_acquireLock()` which can be used to acquire a global exclusive lock
when using the client. It can prevent issues with concurrent access to
local storage.

`navigatorLock` is an experimental lock that can be used to test the
library before officially designating it (or an alternative) as the
default global exclusive locking mechanism. It will be rolled out on
supabase.com/dashboard over time as we grow our confidence in the
library.
  • Loading branch information
hf authored Jul 18, 2023
1 parent 3fc3b52 commit 406e95e
Show file tree
Hide file tree
Showing 4 changed files with 297 additions and 52 deletions.
160 changes: 108 additions & 52 deletions src/GoTrueClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,12 @@ import type {
MFAChallengeAndVerifyParams,
ResendParams,
AuthFlowType,
LockFunc,
} from './lib/types'

polyfillGlobalThis() // Make "globalThis" available

const DEFAULT_OPTIONS: Omit<Required<GoTrueClientOptions>, 'fetch' | 'storage'> = {
const DEFAULT_OPTIONS: Omit<Required<GoTrueClientOptions>, 'fetch' | 'storage' | 'lock'> = {
url: GOTRUE_URL,
storageKey: STORAGE_KEY,
autoRefreshToken: true,
Expand All @@ -99,6 +100,10 @@ const AUTO_REFRESH_TICK_DURATION = 30 * 1000
* A token refresh will be attempted this many ticks before the current session expires. */
const AUTO_REFRESH_TICK_THRESHOLD = 3

async function lockNoOp<R>(name: string, acquireTimeout: number, fn: () => Promise<R>): Promise<R> {
return await fn()
}

export default class GoTrueClient {
private static nextInstanceID = 0

Expand Down Expand Up @@ -146,6 +151,7 @@ export default class GoTrueClient {
[key: string]: string
}
protected fetch: Fetch
protected lock: LockFunc

/**
* Used to broadcast state change events to other tabs listening.
Expand Down Expand Up @@ -183,6 +189,7 @@ export default class GoTrueClient {
this.url = settings.url
this.headers = settings.headers
this.fetch = resolveFetch(settings.fetch)
this.lock = settings.lock || lockNoOp
this.detectSessionInUrl = settings.detectSessionInUrl
this.flowType = settings.flowType

Expand Down Expand Up @@ -255,61 +262,65 @@ export default class GoTrueClient {
throw new Error('Double call of #_initialize()')
}

this.initializePromise = stackGuard('_initialize', async () => {
try {
const isPKCEFlow = isBrowser() ? await this._isPKCEFlow() : false
this._debug('#_initialize()', 'begin', 'is PKCE flow', isPKCEFlow)
this.initializePromise = this._acquireLock(
-1,
async () =>
await stackGuard('_initialize', async () => {
try {
const isPKCEFlow = isBrowser() ? await this._isPKCEFlow() : false
this._debug('#_initialize()', 'begin', 'is PKCE flow', isPKCEFlow)

if (isPKCEFlow || (this.detectSessionInUrl && this._isImplicitGrantFlow())) {
const { data, error } = await this._getSessionFromUrl(isPKCEFlow)
if (error) {
this._debug('#_initialize()', 'error detecting session from URL', error)
if (isPKCEFlow || (this.detectSessionInUrl && this._isImplicitGrantFlow())) {
const { data, error } = await this._getSessionFromUrl(isPKCEFlow)
if (error) {
this._debug('#_initialize()', 'error detecting session from URL', error)

// failed login attempt via url,
// remove old session as in verifyOtp, signUp and signInWith*
await this._removeSession()
// failed login attempt via url,
// remove old session as in verifyOtp, signUp and signInWith*
await this._removeSession()

return { error }
}
return { error }
}

const { session, redirectType } = data
const { session, redirectType } = data

this._debug(
'#_initialize()',
'detected session in URL',
session,
'redirect type',
redirectType
)
this._debug(
'#_initialize()',
'detected session in URL',
session,
'redirect type',
redirectType
)

await this._saveSession(session)
await this._saveSession(session)

setTimeout(async () => {
if (redirectType === 'recovery') {
await this._notifyAllSubscribers('PASSWORD_RECOVERY', session)
} else {
await this._notifyAllSubscribers('SIGNED_IN', session)
}
}, 0)
setTimeout(async () => {
if (redirectType === 'recovery') {
await this._notifyAllSubscribers('PASSWORD_RECOVERY', session)
} else {
await this._notifyAllSubscribers('SIGNED_IN', session)
}
}, 0)

return { error: null }
}
// no login attempt via callback url try to recover session from storage
await this._recoverAndRefresh()
return { error: null }
} catch (error) {
if (isAuthError(error)) {
return { error }
}
return { error: null }
}
// no login attempt via callback url try to recover session from storage
await this._recoverAndRefresh()
return { error: null }
} catch (error) {
if (isAuthError(error)) {
return { error }
}

return {
error: new AuthUnknownError('Unexpected error during initialization', error),
}
} finally {
await this._handleVisibilityChange()
this._debug('#_initialize()', 'end')
}
})
return {
error: new AuthUnknownError('Unexpected error during initialization', error),
}
} finally {
await this._handleVisibilityChange()
this._debug('#_initialize()', 'end')
}
})
)

return await this.initializePromise
}
Expand Down Expand Up @@ -781,6 +792,34 @@ export default class GoTrueClient {
})
}

/**
* Acquires a global lock based on the storage key.
*/
private async _acquireLock<R>(acquireTimeout: number, fn: () => Promise<R>): Promise<R> {
this._debug('#_acquireLock', 'begin', acquireTimeout)

try {
if (isInStackGuard('_acquireLock')) {
this._debug('#_acquireLock', 'recursive call')
return await fn()
}

return await this.lock(`lock:${this.storageKey}`, acquireTimeout, async () => {
this._debug('#_acquireLock', 'lock acquired for storage key', this.storageKey)

try {
return await stackGuard('_acquireLock', async () => {
return await fn()
})
} finally {
this._debug('#_acquireLock', 'lock released for storage key', this.storageKey)
}
})
} finally {
this._debug('#_acquireLock', 'end')
}
}

/**
* Use instead of {@link #getSession} inside the library. It is
* semantically usually what you want, as getting a session involves some
Expand Down Expand Up @@ -810,12 +849,29 @@ export default class GoTrueClient {
}
) => Promise<R>
): Promise<R> {
return await stackGuard('_useSession', async () => {
// the use of __loadSession here is the only correct use of the function!
const result = await this.__loadSession()
this._debug('#_useSession', 'begin')

return await fn(result)
})
try {
if (isInStackGuard('_useSession')) {
this._debug('#_useSession', 'recursive call')

// the use of __loadSession here is the only correct use of the function!
const result = await this.__loadSession()

return await fn(result)
}

return await this._acquireLock(-1, async () => {
return await stackGuard('_useSession', async () => {
// the use of __loadSession here is the only correct use of the function!
const result = await this.__loadSession()

return await fn(result)
})
})
} finally {
this._debug('#_useSession', 'end')
}
}

/**
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@ import GoTrueClient from './GoTrueClient'
export { GoTrueAdminApi, GoTrueClient }
export * from './lib/types'
export * from './lib/errors'
export {
navigatorLock,
NavigatorLockAcquireTimeoutError,
internals as lockInternals,
} from './lib/locks'
161 changes: 161 additions & 0 deletions src/lib/locks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* @experimental
*/
export const internals = {
/**
* @experimental
*/
debug: !!(
globalThis &&
globalThis.localStorage &&
globalThis.localStorage.getItem('supabase.gotrue-js.locks.debug') === 'true'
),
}

export class NavigatorLockAcquireTimeoutError extends Error {
public readonly isAcquireTimeout = true

constructor(message: string) {
super(message)
}
}

/**
* Implements a global exclusive lock using the Navigator LockManager API. It
* is available on all browsers released after 2022-03-15 with Safari being the
* last one to release support. If the API is not available, this function will
* throw. Make sure you check availablility before configuring {@link
* GoTrueClient}.
*
* You can turn on debugging by setting the `supabase.gotrue-js.locks.debug`
* local storage item to `true`.
*
* Internals:
*
* Since the LockManager API does not preserve stack traces for the async
* function passed in the `request` method, a trick is used where acquiring the
* lock releases a previously started promise to run the operation in the `fn`
* function. The lock waits for that promise to finish (with or without error),
* while the function will finally wait for the result anyway.
*
* @experimental
*
* @param name Name of the lock to be acquired.
* @param acquireTimeout If negative, no timeout. If 0 an error is thrown if
* the lock can't be acquired without waiting. If positive, the lock acquire
* will time out after so many milliseconds. An error is
* a timeout if it has `isAcquireTimeout` set to true.
* @param fn The operation to run once the lock is acquired.
*/
export async function navigatorLock<R>(
name: string,
acquireTimeout: number,
fn: () => Promise<R>
): Promise<R> {
if (internals.debug) {
console.log('@supabase/gotrue-js: navigatorLock: acquire lock', name, acquireTimeout)
}

let beginOperation: (() => void) | null = null
let rejectOperation: ((error: any) => void) | null = null
const beginOperationPromise = new Promise<void>((accept, reject) => {
beginOperation = accept
rejectOperation = reject
})

// this lets us preserve stack traces over the operation, which the
// navigator.locks.request function does not preserve well still
const result = (async () => {
await beginOperationPromise

if (internals.debug) {
console.log('@supabase/gotrue-js: navigatorLock: operation start')
}

try {
return await fn()
} finally {
if (internals.debug) {
console.log('@supabase/gotrue-js: navigatorLock: operation end')
}
}
})()

const abortController = new globalThis.AbortController()

if (acquireTimeout > 0) {
setTimeout(() => {
beginOperation = null
abortController.abort()

if (rejectOperation) {
if (internals.debug) {
console.log('@supabase/gotrue-js: navigatorLock acquire timed out', name)
}

if (rejectOperation) {
rejectOperation(
new NavigatorLockAcquireTimeoutError(
`Acquiring an exclusive Navigator LockManager lock "${name}" timed out after ${acquireTimeout}ms`
)
)
}
beginOperation = null
rejectOperation = null
}
}, acquireTimeout)
}

await globalThis.navigator.locks.request(
name,
{
mode: 'exclusive',
ifAvailable: acquireTimeout === 0,
signal: abortController.signal,
},
async (lock) => {
if (lock) {
if (internals.debug) {
console.log('@supabase/gotrue-js: navigatorLock acquired', name)
}

try {
if (beginOperation) {
beginOperation()
beginOperation = null
rejectOperation = null
await result
}
} catch (e: any) {
// not important to handle the error here
} finally {
if (internals.debug) {
console.log('@supabase/gotrue-js: navigatorLock released', name)
}
}
} else {
if (internals.debug) {
console.log('@supabase/gotrue-js: navigatorLock not immediately available', name)
}

// no lock was available because acquireTimeout === 0
const timeout: any = new Error(
`Acquiring an exclusive Navigator LockManager lock "${name}" immediately failed`
)
timeout.isAcquireTimeout = true

if (rejectOperation) {
rejectOperation(
new NavigatorLockAcquireTimeoutError(
`Acquiring an exclusive Navigator LockManager lock "${name}" immediately failed`
)
)
}
beginOperation = null
rejectOperation = null
}
}
)

return await result
}
Loading

0 comments on commit 406e95e

Please sign in to comment.