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 : }
|