| /** | |
| * Lock primitives retained for backwards-compatible imports. The auth client | |
| * coordinates refreshes itself (deduping in-instance callers onto a shared | |
| * in-flight promise) and lets the GoTrue server resolve cross-instance races, | |
| * so it does not invoke any primitive from this module. The functions still | |
| * work for direct callers that need a navigator.locks-backed or in-process | |
| * exclusive lock of their own. | |
| */ | |
| import { supportsLocalStorage } from './helpers' | |
| /** | |
| * @deprecated Debug flag for `navigatorLock` / `processLock`. The auth | |
| * client ignores both, so this has no client-side effect. | |
| * @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. | |
| * | |
| * @deprecated The auth client doesn't acquire locks around auth operations, | |
| * so this error never originates from `supabase.auth.*` calls. Direct callers | |
| * of `navigatorLock` / `processLock` still receive it on acquire timeout. | |
| */ | |
| export abstract class LockAcquireTimeoutError extends Error { | |
| public readonly isAcquireTimeout = true | |
| constructor(message: string) { | |
| super(message) | |
| } | |
| } | |
| /** | |
| * @deprecated The auth client doesn't call `navigator.locks`, so this error | |
| * never originates from `supabase.auth.*` calls. Direct callers of | |
| * `navigatorLock` still receive it on acquire timeout. | |
| */ | |
| export class NavigatorLockAcquireTimeoutError extends LockAcquireTimeoutError {} | |
| /** | |
| * @deprecated The auth client doesn't run `processLock`, so this error | |
| * never originates from `supabase.auth.*` calls. Direct callers of | |
| * `processLock` still receive it on acquire timeout. | |
| */ | |
| 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. | |
| * | |
| * @deprecated The auth client coordinates refreshes itself and the server | |
| * resolves concurrent refresh races, so passing `{ lock: navigatorLock }` | |
| * to it has no effect. You can safely drop the import from your client setup. | |
| */ | |
| 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) | |
| } | |
| const abortController = new globalThis.AbortController() | |
| let acquireTimeoutTimer: ReturnType<typeof setTimeout> | 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<any> } = {} | |
| /** | |
| * 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. | |
| * | |
| * @deprecated The auth client coordinates refreshes itself and the server | |
| * resolves concurrent refresh races, so passing `{ lock: processLock }` | |
| * to it has no effect. You can safely drop the import from your client setup. | |
| * | |
| * @example | |
| * ```ts | |
| * await processLock('migrate', 5000, async () => { | |
| * await runMigration() | |
| * }) | |
| * ``` | |
| */ | |
| export async function processLock<R>( | |
| name: string, | |
| acquireTimeout: number, | |
| fn: () => Promise<R> | |
| ): Promise<R> { | |
| 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<typeof setTimeout> | 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 | |
| } | |