import { supportsLocalStorage } from './helpers' /** * @experimental */ export const internals = { /** * @experimental */ debug: !!( globalThis && supportsLocalStorage() && globalThis.localStorage && globalThis.localStorage.getItem('supabase.gotrue-js.locks.debug') === 'true' ), } /** * An error thrown when a lock cannot be acquired after some amount of time. * * Use the {@link #isAcquireTimeout} property instead of checking with `instanceof`. * * @example * ```ts * import { LockAcquireTimeoutError } from '@supabase/auth-js' * * class CustomLockError extends LockAcquireTimeoutError { * constructor() { * super('Lock timed out') * } * } * ``` */ export abstract class LockAcquireTimeoutError extends Error { public readonly isAcquireTimeout = true constructor(message: string) { super(message) } } /** * Error thrown when the browser Navigator Lock API fails to acquire a lock. * * @example * ```ts * import { NavigatorLockAcquireTimeoutError } from '@supabase/auth-js' * * throw new NavigatorLockAcquireTimeoutError('Lock timed out') * ``` */ export class NavigatorLockAcquireTimeoutError extends LockAcquireTimeoutError {} /** * Error thrown when the process-level lock helper cannot acquire a lock. * * @example * ```ts * import { ProcessLockAcquireTimeoutError } from '@supabase/auth-js' * * throw new ProcessLockAcquireTimeoutError('Lock timed out') * ``` */ export class ProcessLockAcquireTimeoutError extends LockAcquireTimeoutError {} /** * 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. * * @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. * @example * ```ts * await navigatorLock('sync-user', 1000, async () => { * await refreshSession() * }) * ``` */ export async function navigatorLock( name: string, acquireTimeout: number, fn: () => Promise ): Promise { if (internals.debug) { console.log('@supabase/gotrue-js: navigatorLock: acquire lock', name, acquireTimeout) } const abortController = new globalThis.AbortController() let acquireTimeoutTimer: ReturnType | undefined if (acquireTimeout > 0) { acquireTimeoutTimer = setTimeout(() => { abortController.abort() if (internals.debug) { console.log('@supabase/gotrue-js: navigatorLock acquire timed out', name) } }, acquireTimeout) } // MDN article: https://developer.mozilla.org/en-US/docs/Web/API/LockManager/request // Wrapping navigator.locks.request() with a plain Promise is done as some // libraries like zone.js patch the Promise object to track the execution // context. However, it appears that most browsers use an internal promise // implementation when using the navigator.locks.request() API causing them // to lose context and emit confusing log messages or break certain features. // This wrapping is believed to help zone.js track the execution context // better. await Promise.resolve() try { return await globalThis.navigator.locks.request( name, acquireTimeout === 0 ? { mode: 'exclusive', ifAvailable: true, } : { mode: 'exclusive', signal: abortController.signal, }, async (lock) => { if (lock) { // Lock acquired — cancel the acquire-timeout timer so it cannot fire // while fn() is running. Without this, a delayed timeout abort would // set signal.aborted = true even though we already hold the lock, // causing a subsequent steal to be misclassified as "our timeout // fired" and triggering a spurious steal-back cascade. clearTimeout(acquireTimeoutTimer) if (internals.debug) { console.log('@supabase/gotrue-js: navigatorLock: acquired', name, lock.name) } try { return await fn() } finally { if (internals.debug) { console.log('@supabase/gotrue-js: navigatorLock: released', name, lock.name) } } } else { if (acquireTimeout === 0) { if (internals.debug) { console.log('@supabase/gotrue-js: navigatorLock: not immediately available', name) } throw new NavigatorLockAcquireTimeoutError( `Acquiring an exclusive Navigator LockManager lock "${name}" immediately failed` ) } else { if (internals.debug) { try { const result = await globalThis.navigator.locks.query() console.log( '@supabase/gotrue-js: Navigator LockManager state', JSON.stringify(result, null, ' ') ) } catch (e) { console.warn( '@supabase/gotrue-js: Error when querying Navigator LockManager state', e ) } } // Browser is not following the Navigator LockManager spec, it // returned a null lock when we didn't use ifAvailable. So we can // pretend the lock is acquired in the name of backward compatibility // and user experience and just run the function. console.warn( '@supabase/gotrue-js: Navigator LockManager returned a null lock when using #request without ifAvailable set to true, it appears this browser is not following the LockManager spec https://developer.mozilla.org/en-US/docs/Web/API/LockManager/request' ) clearTimeout(acquireTimeoutTimer) return await fn() } } } ) } catch (e) { // Always clear the acquire timeout once the request settles, so it cannot // fire later and incorrectly abort/log after a rejection. if (acquireTimeout > 0) { clearTimeout(acquireTimeoutTimer) } // DOMException does not extend Error in Node.js, so use structural check if ( e !== null && typeof e === 'object' && 'name' in e && e.name === 'AbortError' && acquireTimeout > 0 ) { if (abortController.signal.aborted) { // OUR timeout fired — the lock is genuinely orphaned. Steal it. // // The lock acquisition was aborted because the timeout fired while the // request was still pending. This typically means another lock holder is // not releasing the lock, possibly due to React Strict Mode's // double-mount/unmount behavior or a component unmounting mid-operation, // leaving an orphaned lock. // // Recovery: use { steal: true } to forcefully acquire the lock. Per the // Web Locks API spec, this releases any currently held lock with the same // name and grants the request immediately, preempting any queued requests. // The previous holder's callback continues running to completion but no // longer holds the lock for exclusion purposes. // // See: https://github.com/supabase/supabase/issues/42505 if (internals.debug) { console.log( '@supabase/gotrue-js: navigatorLock: acquire timeout, recovering by stealing lock', name ) } console.warn( `@supabase/gotrue-js: Lock "${name}" was not released within ${acquireTimeout}ms. ` + 'This may indicate an orphaned lock from a component unmount (e.g., React Strict Mode). ' + 'Forcefully acquiring the lock to recover.' ) return await Promise.resolve().then(() => globalThis.navigator.locks.request( name, { mode: 'exclusive', steal: true, }, async (lock) => { if (lock) { if (internals.debug) { console.log( '@supabase/gotrue-js: navigatorLock: recovered (stolen)', name, lock.name ) } try { return await fn() } finally { if (internals.debug) { console.log( '@supabase/gotrue-js: navigatorLock: released (stolen)', name, lock.name ) } } } else { // This should not happen with steal: true, but handle gracefully. console.warn( '@supabase/gotrue-js: Navigator LockManager returned null lock even with steal: true' ) return await fn() } } ) ) } else { // We HELD the lock but another request stole it from us. // Per the Web Locks spec, our fn() callback is still running as an // orphaned background task — do NOT steal back. Stealing back would // cause a cascade (A steals B, B steals A, ...) and run fn() a second // time concurrently, corrupting auth state. // Convert to a typed error so callers (e.g. _autoRefreshTokenTick) // can handle/filter it without it leaking to Sentry as a raw AbortError. if (internals.debug) { console.log( '@supabase/gotrue-js: navigatorLock: lock was stolen by another request', name ) } throw new NavigatorLockAcquireTimeoutError( `Lock "${name}" was released because another request stole it` ) } } throw e } } const PROCESS_LOCKS: { [name: string]: Promise } = {} /** * Implements a global exclusive lock that works only in the current process. * Useful for environments like React Native or other non-browser * single-process (i.e. no concept of "tabs") environments. * * Use {@link #navigatorLock} in browser environments. * * @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. * @example * ```ts * await processLock('migrate', 5000, async () => { * await runMigration() * }) * ``` */ export async function processLock( name: string, acquireTimeout: number, fn: () => Promise ): Promise { const previousOperation = PROCESS_LOCKS[name] ?? Promise.resolve() // Wrap previousOperation to handle errors without using .catch() // This avoids Firefox content script security errors const previousOperationHandled = (async () => { try { await previousOperation return null } catch (e) { // ignore error of previous operation that we're waiting to finish return null } })() const currentOperation = (async () => { let timeoutId: ReturnType | null = null try { // Wait for either previous operation or timeout const timeoutPromise = acquireTimeout >= 0 ? new Promise((_, reject) => { timeoutId = setTimeout(() => { console.warn( `@supabase/gotrue-js: Lock "${name}" acquisition timed out after ${acquireTimeout}ms. ` + 'This may be caused by another operation holding the lock. ' + 'Consider increasing lockAcquireTimeout or checking for stuck operations.' ) reject( new ProcessLockAcquireTimeoutError( `Acquiring process lock with name "${name}" timed out` ) ) }, acquireTimeout) }) : null await Promise.race([previousOperationHandled, timeoutPromise].filter((x) => x)) // If we reach here, previousOperationHandled won the race // Clear the timeout to prevent false warnings if (timeoutId !== null) { clearTimeout(timeoutId) } } catch (e) { // Clear the timeout on error path as well if (timeoutId !== null) { clearTimeout(timeoutId) } // Re-throw timeout errors, ignore others if (e instanceof LockAcquireTimeoutError) { throw e } // Fall through to run fn() - previous operation finished with error } // Previous operations finished and we didn't get a race on the acquire // timeout, so the current operation can finally start return await fn() })() PROCESS_LOCKS[name] = (async () => { try { return await currentOperation } catch (e) { if (e instanceof LockAcquireTimeoutError) { // if the current operation timed out, it doesn't mean that the previous // operation finished, so we need continue waiting for it to finish try { await previousOperation } catch (prevError) { // Ignore previous operation errors } return null } throw e } })() // finally wait for the current operation to finish successfully, with an // error or with an acquire timeout error return await currentOperation }