LCOV - code coverage report
Current view: top level - dashql-app/src/platform/crosstab - app_ownership.ts Coverage Total Hit
Test: DashQL 915828b Lines: 98.8 % 81 80
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : /// The Web Lock that the tab owning the app holds while it runs
       2              : export const APP_OWNERSHIP_LOCK = 'dashql-app';
       3              : /// The channel on which tabs ask the owner to hand over the app
       4              : export const APP_OWNERSHIP_CHANNEL = 'dashql-app';
       5              : /// The message that asks the owner to hand over the app
       6            1 : const HANDOFF_REQUEST = 'handoff';
       7              : 
       8              : /// How a tab reaches the other tabs of the app
       9              : export interface AppOwnershipEnvironment {
      10              :     /// The lock manager of the tab
      11              :     locks: LockManager;
      12              :     /// Open a broadcast channel of the tab
      13              :     openChannel: (name: string) => BroadcastChannel;
      14              :     /// The name of the ownership lock
      15              :     lockName?: string;
      16              :     /// The name of the handoff channel
      17              :     channelName?: string;
      18              : }
      19              : 
      20              : /// The default environment of the current tab, null if the browser has no Web Locks
      21            1 : export function getAppOwnershipEnvironment(): AppOwnershipEnvironment | null {
      22            1 :     if (typeof navigator === 'undefined' || navigator.locks == null || typeof BroadcastChannel === 'undefined') {
      23            1 :         return null;
      24              :     }
      25            1 :     return { locks: navigator.locks, openChannel: name => new BroadcastChannel(name) };
      26            1 : }
      27              : 
      28              : /// The owner did not hand over the app in time
      29            1 : export class AppOwnershipTimeoutError extends Error {
      30            1 :     constructor() {
      31            1 :         super('The tab that owns the app did not hand it over');
      32            1 :         this.name = 'AppOwnershipTimeoutError';
      33            1 :     }
      34            1 : }
      35              : 
      36              : /// The ownership of the app storage by one tab.
      37              : ///
      38              : /// All tabs of the origin share the OPFS root, and every tab keeps the state it loaded in memory. Only
      39              : /// one tab may therefore load and write the storage at a time. That tab holds the Web Lock of the app
      40              : /// until it is closed or hands the app over.
      41              : ///
      42              : /// A tab takes over by queueing a request for the lock and asking the owner on the broadcast channel.
      43              : /// The owner stops writing and returns from the lock callback, the lock manager then grants the
      44              : /// lock to the queued request. The new owner cannot start before the old owner stopped, so the
      45              : /// handoff needs no acknowledgement. The lock is never stolen: an owner that doesn't respond keeps
      46              : /// the app until its tab is closed.
      47            1 : export class AppOwnership {
      48              :     /// Resolves when the lock was released, after a handoff or `dispose`
      49            1 :     public readonly released: Promise<void>;
      50              : 
      51            1 :     private constructor(
      52            1 :         private readonly channel: BroadcastChannel,
      53            1 :         private readonly release: () => void,
      54            1 :         released: Promise<void>,
      55            1 :     ) {
      56            1 :         this.released = released;
      57            1 :     }
      58              : 
      59              :     /// Acquire the ownership if no other tab owns the app.
      60              :     ///
      61              :     /// `handOver` stops every writer of the app storage when another tab asks for the app. The lock is
      62              :     /// released when it settles.
      63            1 :     static async tryAcquire(env: AppOwnershipEnvironment, handOver: () => Promise<void>): Promise<AppOwnership | null> {
      64            1 :         return await AppOwnership.hold(env, handOver, { ifAvailable: true }, crypto.randomUUID());
      65            1 :     }
      66              : 
      67              :     /// Take over the app from the owning tab.
      68              :     ///
      69              :     /// Rejects with `AppOwnershipTimeoutError` when the owner doesn't hand over the app within the
      70              :     /// timeout. The request is withdrawn then, the owner keeps the app.
      71            1 :     static async takeOver(env: AppOwnershipEnvironment, handOver: () => Promise<void>, timeoutMs: number): Promise<AppOwnership> {
      72            1 :         const requester = crypto.randomUUID();
      73            1 :         const abort = new AbortController();
      74            1 :         const timer = setTimeout(() => abort.abort(), timeoutMs);
      75            1 :         const acquired = AppOwnership.hold(env, handOver, { signal: abort.signal }, requester, () => clearTimeout(timer));
      76              :         // Lock requests and queries share the queue of the lock manager. Once the query returns, the
      77              :         // request is queued and receives the lock when the owner releases it.
      78            1 :         await env.locks.query();
      79            1 :         const channel = env.openChannel(env.channelName ?? APP_OWNERSHIP_CHANNEL);
      80            1 :         channel.postMessage({ type: HANDOFF_REQUEST, requester });
      81            1 :         channel.close();
      82            1 :         try {
      83            1 :             const ownership = await acquired;
      84            1 :             return ownership!;
      85            1 :         } catch (error) {
      86            1 :             if ((error as DOMException).name === 'AbortError') {
      87            1 :                 throw new AppOwnershipTimeoutError();
      88              :             }
      89            0 :             throw error;
      90            1 :         } finally {
      91            1 :             clearTimeout(timer);
      92            1 :         }
      93            1 :     }
      94              : 
      95              :     /// Hold the lock until the ownership is handed over or released
      96            1 :     private static hold(
      97            1 :         env: AppOwnershipEnvironment,
      98            1 :         handOver: () => Promise<void>,
      99            1 :         options: LockOptions,
     100            1 :         id: string,
     101            1 :         onGranted?: () => void,
     102            1 :     ): Promise<AppOwnership | null> {
     103            1 :         return new Promise<AppOwnership | null>((resolve, reject) => {
     104            1 :             const request = env.locks.request(env.lockName ?? APP_OWNERSHIP_LOCK, options, async lock => {
     105            1 :                 if (lock == null) {
     106            1 :                     resolve(null);
     107            1 :                     return;
     108            1 :                 }
     109            1 :                 onGranted?.();
     110            1 :                 let release!: () => void;
     111            1 :                 const done = new Promise<void>(r => { release = r; });
     112            1 :                 const channel = env.openChannel(env.channelName ?? APP_OWNERSHIP_CHANNEL);
     113            1 :                 let handingOver = false;
     114            1 :                 channel.onmessage = (event: MessageEvent) => {
     115              :                     // A tab that finds the lock free still asks for it, it must not hand over to itself
     116            1 :                     if (event.data?.type !== HANDOFF_REQUEST || event.data.requester === id || handingOver) {
     117            1 :                         return;
     118              :                     }
     119            1 :                     handingOver = true;
     120              :                     // The lock is released when the writers stopped, even if stopping them failed
     121            1 :                     handOver().catch(() => { }).then(release);
     122            1 :                 };
     123              :                 // The lock is free only once this callback returned, the request settles then
     124            1 :                 const released = request.then(() => { }, () => { });
     125            1 :                 resolve(new AppOwnership(channel, release, released));
     126            1 :                 await done;
     127            1 :                 channel.close();
     128            1 :             });
     129            1 :             request.catch(reject);
     130            1 :         });
     131            1 :     }
     132              : 
     133              :     /// Release the ownership without a handoff, e.g. when the app is unmounted
     134            1 :     public dispose(): void {
     135            1 :         this.channel.onmessage = null;
     136            1 :         this.release();
     137            1 :     }
     138            1 : }
        

Generated by: LCOV version 2.0-1