Skip to content

Commit

Permalink
feat: add _acquireLock and navigatorLock
Browse files Browse the repository at this point in the history
  • Loading branch information
hf committed Jul 15, 2023
1 parent e3ba99e commit e40b174
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 e40b174

Please sign in to comment.