From 91a3f04ea9a29874ba20c2dc7879bec700b1e09c Mon Sep 17 00:00:00 2001 From: Daniel Jagszent Date: Sun, 9 Feb 2025 18:30:08 +0100 Subject: [PATCH] feat: add useWebLocks (Web Locks API) and useLeaderElection (all open tabs elect a leader among them) --- packages/core/index.ts | 2 + packages/core/useLeaderElection/demo.vue | 88 +++++++ .../useLeaderElection/index.browser.test.ts | 90 ++++++++ packages/core/useLeaderElection/index.md | 68 ++++++ packages/core/useLeaderElection/index.test.ts | 21 ++ packages/core/useLeaderElection/index.ts | 91 ++++++++ packages/core/useWebLocks/demo.vue | 126 ++++++++++ .../core/useWebLocks/index.browser.test.ts | 187 +++++++++++++++ packages/core/useWebLocks/index.md | 145 ++++++++++++ packages/core/useWebLocks/index.test.ts | 27 +++ packages/core/useWebLocks/index.ts | 218 ++++++++++++++++++ 11 files changed, 1063 insertions(+) create mode 100644 packages/core/useLeaderElection/demo.vue create mode 100644 packages/core/useLeaderElection/index.browser.test.ts create mode 100644 packages/core/useLeaderElection/index.md create mode 100644 packages/core/useLeaderElection/index.test.ts create mode 100644 packages/core/useLeaderElection/index.ts create mode 100644 packages/core/useWebLocks/demo.vue create mode 100644 packages/core/useWebLocks/index.browser.test.ts create mode 100644 packages/core/useWebLocks/index.md create mode 100644 packages/core/useWebLocks/index.test.ts create mode 100644 packages/core/useWebLocks/index.ts diff --git a/packages/core/index.ts b/packages/core/index.ts index 7a98fe414a1..16031829b32 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -64,6 +64,7 @@ export * from './useImage' export * from './useInfiniteScroll' export * from './useIntersectionObserver' export * from './useKeyModifier' +export * from './useLeaderElection' export * from './useLocalStorage' export * from './useMagicKeys' export * from './useManualRefHistory' @@ -134,6 +135,7 @@ export * from './useVModels' export * from './useVibrate' export * from './useVirtualList' export * from './useWakeLock' +export * from './useWebLocks' export * from './useWebNotification' export * from './useWebSocket' export * from './useWebWorker' diff --git a/packages/core/useLeaderElection/demo.vue b/packages/core/useLeaderElection/demo.vue new file mode 100644 index 00000000000..8d9b0d289e0 --- /dev/null +++ b/packages/core/useLeaderElection/demo.vue @@ -0,0 +1,88 @@ + + + diff --git a/packages/core/useLeaderElection/index.browser.test.ts b/packages/core/useLeaderElection/index.browser.test.ts new file mode 100644 index 00000000000..3f9c46a5813 --- /dev/null +++ b/packages/core/useLeaderElection/index.browser.test.ts @@ -0,0 +1,90 @@ +import { mount } from '@vue/test-utils' +import { useLeaderElection, useWebLocksAbortScopeDisposed } from '@vueuse/core' +import { promiseTimeout } from '@vueuse/shared' +import { describe, expect, it } from 'vitest' +import { type Ref, ref } from 'vue' + +function nextFrame() { + return promiseTimeout(15) // we assume that browser can handle lock requests in this time +} + +describe('useLeaderElection', () => { + it('should be supported', () => { + const { isSupported } = useLeaderElection({ name: 'vitest-leader-1' }) + expect(isSupported.value).toBe(true) + }) + it('should work as expected', async () => { + const component = { + template: '', + setup() { + const { isSupported, isLeader } = useLeaderElection({ name: 'vitest-leader-2' }) + return { isSupported, isLeader } + }, + } + const wrapper1 = mount(component) + const vm1 = wrapper1.vm + expect(vm1.isSupported).toBe(true) + await nextFrame() + expect(vm1.isLeader).toBe(true) + const wrapper2 = mount(component) + const vm2 = wrapper2.vm + expect(vm2.isSupported).toBe(true) + await nextFrame() + expect(vm2.isLeader).toBe(false) + wrapper1.unmount() + wrapper2.unmount() + const wrapper3 = mount(component) + const vm3 = wrapper3.vm + expect(vm3.isSupported).toBe(true) + await nextFrame() + expect(vm3.isLeader).toBe(true) + wrapper3.unmount() + }) + it('should support dynamic names', async () => { + let capturedName: Ref + const component = { + template: '', + setup() { + const name = ref('vitest-leader-3') + const { isSupported, isLeader } = useLeaderElection({ name }) + capturedName = name + return { isSupported, isLeader } + }, + } + const wrapper1 = mount(component) + const vm1 = wrapper1.vm + expect(vm1.isSupported).toBe(true) + await nextFrame() + expect(vm1.isLeader).toBe(true) + capturedName!.value = 'vitest-leader-4' + // isLeader is false right after the name change + expect(vm1.isLeader).toBe(false) + await nextFrame() + expect(vm1.isLeader).toBe(true) + wrapper1.unmount() + }) + it('should abort the workload signal on scope dispose', async () => { + let capturedAsLeader: ReturnType['asLeader'] + const component = { + template: '', + setup() { + const { isSupported, asLeader } = useLeaderElection({ name: 'vitest-leader-3' }) + capturedAsLeader = asLeader + return { isSupported } + }, + } + const wrapper1 = mount(component) + const vm1 = wrapper1.vm + expect(vm1.isSupported).toBe(true) + await nextFrame() + expect(capturedAsLeader!(() => {})).toBe(true) + let capturesReason + capturedAsLeader!(async (signal) => { + await promiseTimeout(250) + capturesReason = signal?.reason + }) + wrapper1.unmount() + await promiseTimeout(300) + expect(capturesReason).toBe(useWebLocksAbortScopeDisposed) + }) +}) diff --git a/packages/core/useLeaderElection/index.md b/packages/core/useLeaderElection/index.md new file mode 100644 index 00000000000..117dea36cfb --- /dev/null +++ b/packages/core/useLeaderElection/index.md @@ -0,0 +1,68 @@ +--- +category: Browser +--- + +# useLeaderElection + +Uses the [Web Locks API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Locks_API) to elect one tab as leader. + +Use this composable to e.g. refresh a session only once even when multiple tabs of your application are open or multiple worker are running. + +## Usage + +```ts +import { useLeaderElection } from '@vueuse/core' + +const { asLeader, isElected, isSupported } = useLeaderElection({ name: 'lock-name' }) +// … later in your component +asLeader(() => { + /* your code that only needs to be executed in one tab */ +}) +``` + +### Example: Shared Session + +You can combine `useLeaderElection` with `useBroadcastChannel` to keep a shared session among all open tabs of your application. +If you log out in one tab you will be logged out in all tabs. Only one tab is responsible to refresh the shared session +periodically: + +```ts +import { useBroadcastChannel, useIntervalFn, useLeaderElection } from '@vueuse/core' +import { computed, watch } from 'vue' + +const { asLeader, isLeader } = useLeaderElection({ name: 'shared-session' }) +interface Session { expires: number, username: string } +const { data, post } = useBroadcastChannel({ name: 'shared-session' }) +const { pause, resume } = useIntervalFn(() => { + if (data.value === null) { + pause() + return + } + asLeader(async (signal) => { + if (data.value === undefined || data.value?.expires > Date.now() - 1000 * 60 * 5) { + pause() + try { + const newSession = (await fetch('/refresh/session', { signal, credentials: 'include' })).json() + data.value = newSession + post(newSession) + } + finally { + resume() + } + } + }) +}) +function logout() { + data.value = null + post(null) +} +const isLoggedIn = computed(() => !!data.value?.username) +watch(isLeader, (leader) => { + if (leader) { + resume() + } + else { + pause() + } +}) +``` diff --git a/packages/core/useLeaderElection/index.test.ts b/packages/core/useLeaderElection/index.test.ts new file mode 100644 index 00000000000..f4719f30c14 --- /dev/null +++ b/packages/core/useLeaderElection/index.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest' +import { useLeaderElection } from '.' + +describe('useLeaderElection', () => { + it('should be defined', () => { + expect(useLeaderElection).toBeDefined() + }) + + it('should be doing work', () => { + const { asLeader } = useLeaderElection({ name: 'vitest-use-once-in-all-tabs' }) + + const fn = vi.fn() + asLeader(fn) + expect(fn).toHaveBeenCalledTimes(1) + }) + + it('should not be supported', () => { + const { isSupported } = useLeaderElection({ name: 'vitest-use-once-in-all-tabs' }) + expect(isSupported.value).toBe(false) + }) +}) diff --git a/packages/core/useLeaderElection/index.ts b/packages/core/useLeaderElection/index.ts new file mode 100644 index 00000000000..9888d4b1d7a --- /dev/null +++ b/packages/core/useLeaderElection/index.ts @@ -0,0 +1,91 @@ +import type { ComputedRef, MaybeRefOrGetter, Ref, WatchOptionsBase } from 'vue' +import type { ConfigurableNavigator } from '../_configurable' +import { tryOnScopeDispose } from '@vueuse/shared' +import { readonly, ref, toValue, watchEffect } from 'vue' +import { defaultNavigator } from '../_configurable' +import { isExpectedWebLockRejection, useWebLocks } from '../useWebLocks' + +export interface UseLeaderElectionOptions extends ConfigurableNavigator, WatchOptionsBase { + /** + * The name of the lock that gets used to elect the one tab that does the work. + * Needs to be uniq throughout the origin. + */ + name: MaybeRefOrGetter +} + +export interface UseLeaderElectionReturn { + /** + * Returns whether the Web Locks API is supported by the current browser. + */ + isSupported: ComputedRef + + /** + * Reactive boolean that tracks whether the current tab was elected as the one tab to do the work. + * If the tab was elected as leader it will stay the leader until the current scope gets disposed. + * Then a new tab becomes the leader. isElected will only ever transition from `false` to `true` in + * the lifetime of a component (when the lock name does not dynamically change). + */ + isLeader: Readonly> + + /** + * Executes the workload function if the Web Locks API is not supported or if the current tab is the elected tab. + * The workload function gets passed a signal that gets aborted when the scope gets disposed/we stop being the leader. + * `signal` will be `undefined` when `isSupported` is `false`. + * + * @return `true` when the workload function was executed `false` otherwise. + */ + asLeader: (workload: (signal?: AbortSignal) => void) => boolean +} + +/** + * Ensure that code gets executed only in one of many tabs. + * + * @see https://vueuse.org/useLeaderElection/ + * @param [options] + */ +export function useLeaderElection(options: UseLeaderElectionOptions): UseLeaderElectionReturn { + const { + name, + navigator = defaultNavigator, + flush = 'sync', + } = options + const { isSupported, request } = useWebLocks>({ navigator }) + const isLeader = ref(false) + let currentSignal: AbortSignal | undefined + const asLeader = (workload: (signal?: AbortSignal) => void) => { + if ((isSupported.value && isLeader.value) || !isSupported.value) { + workload(currentSignal) + return true + } + else { + return false + } + } + if (isSupported.value) { + let currentLock: (() => void) | undefined + const stopWatch = watchEffect(() => { + if (currentLock) { + isLeader.value = false + currentSignal = undefined + currentLock() + currentLock = undefined + } + request(toValue(name), (signal) => { + currentSignal = signal + isLeader.value = true + return new Promise((resolve) => { + currentLock = resolve + }) + }).catch((error) => { + if (!isExpectedWebLockRejection(error)) { + // can be InvalidStateError, SecurityError, or NotSupportedError + // see https://developer.mozilla.org/en-US/docs/Web/API/LockManager/request#exceptions + throw error + } + }) + }, { flush }) + tryOnScopeDispose(stopWatch) + } + + return { isSupported, isLeader: readonly(isLeader), asLeader } +} diff --git a/packages/core/useWebLocks/demo.vue b/packages/core/useWebLocks/demo.vue new file mode 100644 index 00000000000..1119efcd2ef --- /dev/null +++ b/packages/core/useWebLocks/demo.vue @@ -0,0 +1,126 @@ + + + diff --git a/packages/core/useWebLocks/index.browser.test.ts b/packages/core/useWebLocks/index.browser.test.ts new file mode 100644 index 00000000000..0db796e5ed9 --- /dev/null +++ b/packages/core/useWebLocks/index.browser.test.ts @@ -0,0 +1,187 @@ +import { mount } from '@vue/test-utils' +import { + useWebLocks, + useWebLocksAbortLockHeld, + useWebLocksAbortLockStolen, + useWebLocksAbortScopeDisposed, + type UseWebLocksReturn, +} from '@vueuse/core' +import { promiseTimeout } from '@vueuse/shared' +import { beforeEach, describe, expect, it } from 'vitest' + +function nextFrame() { + return promiseTimeout(15) // we assume that browser can handle lock requests in this time +} + +describe('useWebLocks', () => { + it('should be supported', () => { + const { isSupported } = useWebLocks() + expect(isSupported.value).toBe(true) + }) + + it('should let navigator be passed', () => { + const { isSupported } = useWebLocks({ navigator: {} as unknown as Navigator }) + expect(isSupported.value).toBe(false) + }) + + it('should get lock', async () => { + await expect(useWebLocks().request('lock-1', () => true)).resolves.toBe(true) + }) + + it('should get nested locks', async () => { + const { request } = useWebLocks<{ 'lock-1': true, 'lock-2': true }>() + await expect(request('lock-1', async () => await request('lock-2', () => true))).resolves.toBe(true) + }) + + it('should queue lock requests', async () => { + const lockName = 'lock-3' + const { request } = useWebLocks<{ [lockName]: number }>() + let inLock = false + let counter = 0 + async function callback() { + if (inLock) { + throw new Error('requests not serialized') + } + inLock = true + await nextFrame() + inLock = false + return ++counter + } + const calls = [request(lockName, callback), request(lockName, callback), request(lockName, callback)] + await expect(Promise.all(calls)).resolves.toStrictEqual([1, 2, 3]) + }) + + it('should throw error calling request without a callback', async () => { + const { request } = useWebLocks() + await expect(request('_', undefined as unknown as () => void)).rejects.toThrow('callback not provided') + await expect(request('_', {} as unknown as () => void)).rejects.toThrow('callback not provided') + await expect(request('_', {}, undefined as unknown as () => void)).rejects.toThrow('callback not provided') + }) + + describe('should', () => { + let capturedRequest: UseWebLocksReturn['request'] | undefined + let capturedSupported: UseWebLocksReturn['isSupported'] | undefined + const component = { + template: '', + setup() { + const { isSupported, request } = useWebLocks() + capturedRequest = request + capturedSupported = isSupported + }, + } + + beforeEach(() => { + capturedRequest = undefined + capturedSupported = undefined + }) + + it('release lock on unmount', async () => { + const wrapper1 = mount(component) + expect(capturedSupported!.value).toBe(true) + let caughtRejection: any + capturedRequest!('lock-4', () => new Promise(() => { /* never release lock */ })).catch(reason => caughtRejection = reason) + wrapper1.unmount() + await nextFrame() + expect(caughtRejection).toBe(useWebLocksAbortScopeDisposed) + const wrapper2 = mount(component) + await expect(capturedRequest!('lock-4', async () => { + await nextFrame() + return true + })).resolves.toBe(true) + wrapper2.unmount() + }) + + it('throw error calling request after scope was disposed', async () => { + const wrapper1 = mount(component) + wrapper1.unmount() + await nextFrame() + await expect(capturedRequest!('lock-4', () => true)).rejects.toThrow('called request after scope was already disposed') + }) + + it('handle ifAvailable', async () => { + const wrapper1 = mount(component) + expect(await capturedRequest!('lock-5', { ifAvailable: true }, () => true)).toBe(true) + const p = capturedRequest!('lock-5', async () => await promiseTimeout(250)).then() + await expect(capturedRequest!('lock-5', { ifAvailable: true }, () => true)).rejects.toBe(useWebLocksAbortLockHeld) + await p + wrapper1.unmount() + }) + + it('handle steal', async () => { + const wrapper1 = mount(component) + expect(await capturedRequest!('lock-6', { steal: true }, () => true)).toBe(true) + await promiseTimeout(100) + const p = capturedRequest!('lock-6', async () => await promiseTimeout(500)) + const start = Date.now() + expect(await capturedRequest!('lock-6', { steal: true }, () => true)).toBe(true) + expect(Date.now() - start).toBeLessThan(250) + await expect(p).rejects.toBe(useWebLocksAbortLockStolen) + wrapper1.unmount() + }) + + it('handle signal', async () => { + const wrapper1 = mount(component) + const p = capturedRequest!('lock-7', async () => await promiseTimeout(500)) + const abortController = new AbortController() + setTimeout(() => abortController.abort('waited long enough'), 250) + await expect(capturedRequest!('lock-7', { signal: abortController.signal }, () => true)).rejects.toBe('waited long enough') + expect(await p).toBe(undefined) + wrapper1.unmount() + }) + + it('signal only relevant for lock request', async () => { + const wrapper1 = mount(component) + const abortController = new AbortController() + setTimeout(() => abortController.abort('waited long enough'), 50) + expect(await capturedRequest!('lock-8', { signal: abortController.signal }, async (signal) => { + await promiseTimeout(100) + signal.throwIfAborted() + return true + })).toBe(true) + wrapper1.unmount() + }) + }) + + it('should honor forceRelease = false', async () => { + let capturedRequest: UseWebLocksReturn['request'] | undefined + const wrapper1 = mount({ + template: '', + setup() { + const { request } = useWebLocks({ forceRelease: false }) + capturedRequest = request + }, + }) + let capturedSignalReason: any + const p = capturedRequest!('lock-9', async (signal) => { + await promiseTimeout(250) + capturedSignalReason = signal.reason + return true + }) + await nextFrame() + wrapper1.unmount() + await nextFrame() + expect(await p).toBe(true) + expect(capturedSignalReason).toBe(useWebLocksAbortScopeDisposed) + }) + + it('should honor forceRelease = true', async () => { + let capturedRequest: UseWebLocksReturn['request'] | undefined + const wrapper1 = mount({ + template: '', + setup() { + const { request } = useWebLocks({ forceRelease: true }) + capturedRequest = request + }, + }) + let capturedRejection: any + const p = capturedRequest!('lock-10', async () => { + await promiseTimeout(250) + return true + }).catch(reason => capturedRejection = reason) + await nextFrame() + wrapper1.unmount() + await nextFrame() + expect(await p).not.toBe(true) + expect(capturedRejection).toBe(useWebLocksAbortScopeDisposed) + }) +}) diff --git a/packages/core/useWebLocks/index.md b/packages/core/useWebLocks/index.md new file mode 100644 index 00000000000..c9922d2ddea --- /dev/null +++ b/packages/core/useWebLocks/index.md @@ -0,0 +1,145 @@ +--- +category: Browser +outline: deep +--- + +# useWebLocks + +Reactive [Web Locks API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Locks_API) that automatically releases the lock (request) on scope dispose. + +## Usage + +```ts +import { isExpectedWebLockRejection, useWebLocks } from '@vueuse/core' + +const { isSupported, request } = useWebLocks() +if (isSupported.value) { + request('lock-name', (signal) => { + // the lock is held until this callback resolves - check `signal` to see if scope got disposed + return fetch('/some/resource', { signal }).then(res => res.json() as Record) + }).then((data) => { + // do something with fetched data - here the lock is released + }).catch((error) => { + // useWebLocks rejects locks on scope dispose. You should swallow those expected rejections. + if (!isExpectedWebLockRejection(error)) { + throw error + } + }) +} +``` + +### Scope Disposal + +Lock request always automatically get aborted when the current scope gets disposed. + +You can decide what happens with held locks on scope disposal. The default is that they will get rejected: + +```ts +import { useWebLocks, useWebLocksAbortScopeDisposed } from '@vueuse/core' +import { promiseTimeout } from '@vueuse/shared' + +const { request } = useWebLocks({ forceRelease: true /* the default */ }) +request('deadlock', () => { + return new Promise(() => { + /* never resolve */ + }) +}).catch((error) => { + // request always gets rejected with error === useWebLocksAbortScopeDisposed + // does not matter if the request is waiting for the lock or the lock was held + console.error(error) +}) +``` + +If this is not what you want (i.e. you need to hold the lock until your callback is done), then you can configure the +`useWebLocks` composable with `forceRelease` set to `false`: + +```ts +import { useWebLocks, useWebLocksAbortScopeDisposed } from '@vueuse/core' +import { promiseTimeout } from '@vueuse/shared' + +const { request } = useWebLocks({ forceRelease: false }) +request('long-running', () => { + return promiseTimeout(10000) +}).then((_) => { + // the lock gets held for the whole 10 seconds regadles wheter the scope got disposed while the lock was held or not +}).catch((error) => { + // error === useWebLocksAbortScopeDisposed when lock request could not be fulfilled before scope was disposed + console.error(error) +}) +``` + +Regardless of the `forceRelease` option, your lock callback does not automatically get terminated when the scope gets disposed. +Use the `signal` parameter that your callback gets passed in to check if you lost the lock: + +```ts +import { useWebLocks } from '@vueuse/core' +import { promiseTimeout } from '@vueuse/shared' + +const { request } = useWebLocks() +request('long-running', async (signal) => { + // pass the AbortSignal to asynchronous tasks that support them + const data = await fetch('/a/resource', { signal }) + await promiseTimeout(1000) + // manually check if the lock is lost + signal.throwIfAborted() +}) +``` + +### Lock Request Timeouts + +You might want to abort a lock request to e.g. implement a timeout. Use an AbortSignal for that: + +```ts +import { useWebLocks } from '@vueuse/core' +import { promiseTimeout } from '@vueuse/shared' + +const { request } = useWebLocks() +request('long-running', () => promiseTimeout(10000)) +const controller = new AbortController() +setTimeout(() => controller.abort(), 1000) +request('long-running', { signal: controller.signal }, () => {}).catch((error) => { + // error is an AbortError + console.error(error) +}) +``` + +The `signal` request option gets only used to abort lock requests. +If the request was granted and the signal aborts, nothing happens with the lock. + +When you want to use the signal in the lock, too, you need to manually check it: + +```ts +import { useWebLocks } from '@vueuse/core' +import { promiseTimeout } from '@vueuse/shared' + +const { request } = useWebLocks() +const controller = new AbortController() +setTimeout(() => controller.abort(), 1000) +request('long-running', { signal: controller.signal }, async () => { + await promiseTimeout(2000) + controller.signal.throwIfAborted() // this will abort your lock +}) +``` + +### TypeScript Support + +You can pass a mapping of lock names to the return type of their callbacks. +This brings you lock name checking and callback return type checking. + +```ts +import { useWebLocks } from '@vueuse/core' + +const { request } = useWebLocks<{ + 'lock-name-1': Record + 'lock-name-2': void +}>() + +// @ts-expect-error lock name does not aligns with return type +request('lock-name-2', () => ({ a: 1, b: 2 })) + +// @ts-expect-error misspelled lock name +request('lock-name2', () => {}) + +// @ts-expect-error wrong return type +request('lock-name-2', () => true) +``` diff --git a/packages/core/useWebLocks/index.test.ts b/packages/core/useWebLocks/index.test.ts new file mode 100644 index 00000000000..779ebc77010 --- /dev/null +++ b/packages/core/useWebLocks/index.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { isExpectedWebLockRejection, useWebLocks, useWebLocksAbortLockHeld, useWebLocksAbortLockStolen, useWebLocksAbortScopeDisposed } from '.' + +describe('useWebLocks', () => { + it('should be defined', () => { + expect(useWebLocks).toBeDefined() + }) + it('should not be supported', () => { + expect(useWebLocks().isSupported.value).toBe(false) + }) + it('should reject requests', async () => { + await expect(() => useWebLocks().request('lock', () => {})).rejects.toThrowError('Web Locks API or AbortController not supported') + }) +}) + +describe('isExpectedWebLockRejection', () => { + it('should be defined', () => { + expect(isExpectedWebLockRejection).toBeDefined() + }) + it('should work', () => { + expect(isExpectedWebLockRejection(useWebLocksAbortScopeDisposed)).toBe(true) + expect(isExpectedWebLockRejection(useWebLocksAbortLockHeld)).toBe(true) + expect(isExpectedWebLockRejection(useWebLocksAbortLockStolen)).toBe(true) + expect(isExpectedWebLockRejection(undefined)).toBe(false) + expect(isExpectedWebLockRejection(new Error('test'))).toBe(false) + }) +}) diff --git a/packages/core/useWebLocks/index.ts b/packages/core/useWebLocks/index.ts new file mode 100644 index 00000000000..3771f3d3b1d --- /dev/null +++ b/packages/core/useWebLocks/index.ts @@ -0,0 +1,218 @@ +import type { ComputedRef } from 'vue' +import type { ConfigurableNavigator } from '../_configurable' +import { tryOnScopeDispose } from '@vueuse/shared' +import { defaultNavigator } from '../_configurable' +import { useSupported } from '../useSupported' + +export interface UseWebLocksOptions extends ConfigurableNavigator { + /** + * Force the release of all held locks when the scope gets disposed. + * You can set this to `false` when you do need the return value of the callback even if the scope gets disposed + * while the callback gets executed. The default is `true` since you normally do not need the promise resolve normally + * when the scope gets disposed. + * + * Either way of course the callback function does not automatically get terminated when the scope gets disposed. + * Long-running callback function should check the AbortSignal they get as parameter (e.g. call `signal.throwIfAborted()`). + * + * @default true + */ + forceRelease?: boolean +} + +export interface UseWebLocksRequestOptions { + /** + * Either "exclusive" or "shared". The default value is "exclusive". Use "shared" to e.g. implement a one-writer-multiple-reader pattern. + * + * @default "exclusive" + */ + mode?: LockMode + + /** + * If `true`, the lock request will only be granted if it is not already held. + * If it cannot be granted, the request gets rejected with `useWebLocksAbortLockHeld`. + * + * @default false + */ + ifAvailable?: boolean + + /** + * If `true`, then any held locks with the same name will be released, and the request will be granted, preempting any queued requests for it. + * This is an escape hatch to resolve deadlock situations. You should not need to use it. + * All useWebLocks composables currently holding this lock will immediately be rejected with `useWebLocksAbortLockStolen`. + * + * @default false + */ + steal?: boolean + + /** + * An AbortSignal (the signal property of an AbortController); if specified and the AbortController is aborted, + * the lock request is dropped if it was not already granted. + * The lock request always gets aborted when the scope gets disposed. + * Use this to abort lock requests manually (e.g. when the request takes too long). + */ + signal?: AbortSignal +} + +export interface UseWebLocksReturn = Record> { + /** + * Returns whether the Web Locks API is supported by the current browser. + */ + isSupported: ComputedRef + + /** + * Request a web lock. Can be called simultaneously with different lock names to request different locks in parallel. + * Multiple request calls with the same name can be queued. They will get executed sequentially. + * + * @param name name of the lock to request. Cannot start with a hyphen (-). Needs to be unique in the whole origin. + * @param options optional options for the lock. + * @param callback function to call when the lock is held. + * It gets an abort signal as parameter that can be used to bail when the scope gets disposed while the lock is held or another process steals the lock. + * @return Promise that resolves with the return value of callback when the lock is released or gets rejected in error conditions. + * There are three distinct rejection reasons that you can test for: + * + * - `useWebLocksAbortScopeDisposed` signals that the scope was disposed while we tried to get the lock (or while callback was executed and `forceRelease` is `true`) + * - `useWebLocksAbortLockStolen` signals that another process stole the lock + * - `useWebLocksAbortLockHeld` signals that an `ifAvailable` request could not be granted because another process was holding the lock + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/LockManager/request + */ + request: ((name: Name, callback: (signal: AbortSignal) => PromiseLike | ReturnMap[Name]) => Promise>) & + ((name: Name, options: UseWebLocksRequestOptions, callback: (signal: AbortSignal) => PromiseLike | ReturnMap[Name]) => Promise>) +} + +/** + * Symbol that is the requests rejection reason and is the reason of the AbortSignal when the lock (request) was aborted because the current scope was disposed. + */ +export const useWebLocksAbortScopeDisposed = Symbol('useWebLocksAbortScopeDisposed') + +/** + * Symbol that is the requests rejection reason or is the reason of the AbortSignal when the lock was aborted because another process stole the lock. + */ +export const useWebLocksAbortLockStolen = Symbol('useWebLocksAbortLockStolen') + +/** + * Symbol that is the requests rejection reason when the lock request was aborted because `isAvailable` is `true` and the lock is currently held. + */ +export const useWebLocksAbortLockHeld = Symbol('useWebLocksAbortLockHeld') + +/** + * Reactive Web Locks API. The lock (request) automatically gets release on scope dispose. + * + * @see https://vueuse.org/useWebLocks/ + * @see https://developer.mozilla.org/en-US/docs/Web/API/Web_Locks_API + * @param [options] + */ +export function useWebLocks = Record>(options: UseWebLocksOptions = {}): UseWebLocksReturn { + const { forceRelease = true, navigator = defaultNavigator } = options + const isSupported = useSupported(() => navigator?.locks && typeof AbortController === 'function') + const activeRejects = forceRelease ? new Set<(reason: symbol) => void>() : undefined + let scopeDisposedAbortController: AbortController + async function request(name: Name, optionsOrFn: UseWebLocksRequestOptions | ((signal: AbortSignal) => PromiseLike | ReturnMap[Name]), callback?: (signal: AbortSignal) => PromiseLike | ReturnMap[Name]): Promise> { + if (!isSupported.value) { + throw new Error('Web Locks API or AbortController not supported') + } + if (scopeDisposedAbortController.signal.aborted) { + throw new Error('called request after scope was already disposed') + } + const requestOptions = typeof optionsOrFn === 'function' ? {} : optionsOrFn + callback = typeof optionsOrFn === 'function' ? optionsOrFn : callback + if (typeof callback !== 'function') { + throw new TypeError('callback not provided') + } + const { mode = 'exclusive', ifAvailable = false, steal = false, signal } = requestOptions + // This is the only misconfiguration error we need to handle ourselves – all other get thrown natively + if ((ifAvailable || steal) && signal) { + throw new DOMException('signal cannot be used with either ifAvailable or steal set to true', 'NotSupportedError') + } + const requestAbortController = new AbortController() + const tellRequestScopeWasDisposed = () => { + requestAbortController.abort(useWebLocksAbortScopeDisposed) + } + scopeDisposedAbortController.signal.addEventListener('abort', tellRequestScopeWasDisposed) + const tellRequestSignalWasAborted = () => { + requestAbortController.abort(signal!.reason) + } + if (signal) { + signal.addEventListener('abort', tellRequestSignalWasAborted) + } + let inCallback = false + try { + return await navigator!.locks.request(name as string, { + mode, + ifAvailable, + steal, + signal: !ifAvailable && !steal ? requestAbortController.signal : undefined, + }, (lock) => { + // we only use the signal for request aborting. since we have the lock, remove our abort event listener + if (signal) { + signal.removeEventListener('abort', tellRequestSignalWasAborted) + } + if (!lock) { + throw useWebLocksAbortLockHeld + } + return new Promise>((resolve, reject) => { + if (forceRelease) { + activeRejects!.add(reject) + } + inCallback = true + Promise.resolve(callback(requestAbortController.signal)) + .finally(() => { + if (forceRelease) { + activeRejects!.delete(reject) + } + inCallback = false + }) + .then(resolve) + .catch(reject) + }) + }) + } + catch (error) { + // If execution is in the callback there are only two possible reasons we get an error: + // (1) we force release the lock on scope dispose (see tryOnScopeDispose below) + // (2) the lock got stolen + // For the latter case we normalize the provided browser DOMException to our useWebLocksAbortLockStolen symbol. + // The browsers all throw 'AbortError' DOMException errors but their message is different for all browsers. + if (inCallback && error !== useWebLocksAbortScopeDisposed) { + requestAbortController.abort(useWebLocksAbortLockStolen) + throw useWebLocksAbortLockStolen + } + throw error + } + finally { + scopeDisposedAbortController.signal.removeEventListener('abort', tellRequestScopeWasDisposed) + if (signal) { + signal.removeEventListener('abort', tellRequestSignalWasAborted) + } + } + } + + if (isSupported.value) { + scopeDisposedAbortController = new AbortController() + tryOnScopeDispose(() => { + scopeDisposedAbortController.abort(useWebLocksAbortScopeDisposed) + if (forceRelease) { + // We preemptively reject/release all held locks when the scope gets disposed. + // Running callbacks also get notified of the scope disposal via the aborted AbortSignal + activeRejects!.forEach(reject => reject(useWebLocksAbortScopeDisposed)) + activeRejects!.clear() + } + }) + } + + return { isSupported, request } +} + +/** + * Checks if `reason` is an expected promise rejection of the `useWebLocks` composable. + * `useWebLocks` rejects/aborts web lock requests when the current scope gets disposed, when `isAvailable` is `true` but the lock is already held. + * It also aborts running callbacks when the lock got stolen while the callback was still holding it. + * + * @param reason + * @return `true` when you should ignore this rejection reason + */ +export function isExpectedWebLockRejection(reason: unknown): boolean { + return (reason === useWebLocksAbortScopeDisposed) + || (reason === useWebLocksAbortLockHeld) + || (reason === useWebLocksAbortLockStolen) +}