414 lines
14 KiB
TypeScript
414 lines
14 KiB
TypeScript
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<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: any) {
|
|
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: any) {
|
|
// 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)
|
|
}
|
|
|
|
if (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.
|
|
* @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: any) {
|
|
// Clear the timeout on error path as well
|
|
if (timeoutId !== null) {
|
|
clearTimeout(timeoutId)
|
|
}
|
|
|
|
// Re-throw timeout errors, ignore others
|
|
if (e && e.isAcquireTimeout) {
|
|
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: any) {
|
|
if (e && e.isAcquireTimeout) {
|
|
// 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
|
|
}
|