| 1 | import type { PreviewViewportSetting } from "@t3tools/contracts"; |
| 2 | |
| 3 | type BrowserViewportHandler = (setting: PreviewViewportSetting) => Promise<void>; |
| 4 | |
| 5 | export const BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS = 15_000; |
| 6 | |
| 7 | class BrowserViewportCommitTimeoutError extends Error { |
| 8 | override readonly name = "BrowserViewportCommitTimeoutError"; |
| 9 | |
| 10 | constructor(readonly tabId: string) { |
| 11 | super(`Timed out committing the browser viewport for tab ${tabId}`); |
| 12 | } |
| 13 | } |
| 14 | |
| 15 | const handlers = new Map<string, BrowserViewportHandler>(); |
| 16 | const commitTails = new Map<string, Promise<void>>(); |
| 17 | |
| 18 | const queueBrowserViewportMutation = <A>( |
| 19 | tabId: string, |
| 20 | start: () => Promise<A>, |
| 21 | ): { |
| 22 | readonly started: Promise<{ readonly operation: Promise<A> }>; |
| 23 | readonly execution: Promise<A>; |
| 24 | } => { |
| 25 | const previous = commitTails.get(tabId) ?? Promise.resolve(); |
| 26 | const started = previous |
| 27 | .catch(() => undefined) |
| 28 | .then(() => ({ |
| 29 | operation: Promise.resolve().then(start), |
| 30 | })); |
| 31 | const execution = started.then(({ operation }) => operation); |
| 32 | const tail = execution.then(() => undefined); |
| 33 | commitTails.set(tabId, tail); |
| 34 | const clear = () => { |
| 35 | if (commitTails.get(tabId) === tail) commitTails.delete(tabId); |
| 36 | }; |
| 37 | void tail.then(clear, clear); |
| 38 | return { started, execution }; |
| 39 | }; |
| 40 | |
| 41 | /** |
| 42 | * Serializes every server-side viewport mutation for one desktop runtime tab. |
| 43 | * Both visible UI commits and background automation use this queue so a |
| 44 | * compensating rollback cannot overtake a newer resize. |
| 45 | */ |
| 46 | export function runBrowserViewportMutation<A>( |
| 47 | tabId: string, |
| 48 | mutation: () => Promise<A>, |
| 49 | ): Promise<A> { |
| 50 | return queueBrowserViewportMutation(tabId, mutation).execution; |
| 51 | } |
| 52 | |
| 53 | const runHandlerWithTimeout = (tabId: string, operation: Promise<void>): Promise<void> => { |
| 54 | let timeoutId: ReturnType<typeof setTimeout> | undefined; |
| 55 | const timeout = new Promise<never>((_resolve, reject) => { |
| 56 | timeoutId = setTimeout( |
| 57 | () => reject(new BrowserViewportCommitTimeoutError(tabId)), |
| 58 | BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS, |
| 59 | ); |
| 60 | }); |
| 61 | return Promise.race([operation, timeout]).finally(() => { |
| 62 | if (timeoutId !== undefined) clearTimeout(timeoutId); |
| 63 | }); |
| 64 | }; |
| 65 | |
| 66 | export function subscribeBrowserViewportChange( |
| 67 | tabId: string, |
| 68 | handler: BrowserViewportHandler, |
| 69 | ): () => void { |
| 70 | handlers.set(tabId, handler); |
| 71 | return () => { |
| 72 | if (handlers.get(tabId) === handler) handlers.delete(tabId); |
| 73 | }; |
| 74 | } |
| 75 | |
| 76 | export function commitBrowserViewportChange( |
| 77 | tabId: string, |
| 78 | setting: PreviewViewportSetting, |
| 79 | ): Promise<void> { |
| 80 | const { started } = queueBrowserViewportMutation(tabId, () => { |
| 81 | const handler = handlers.get(tabId); |
| 82 | return handler |
| 83 | ? handler(setting) |
| 84 | : Promise.reject(new Error(`No visible browser viewport handler for tab ${tabId}`)); |
| 85 | }); |
| 86 | // The queue follows the real handler lifetime, while the caller-facing |
| 87 | // timeout starts only once this commit reaches the front of that queue. |
| 88 | const result = started.then(({ operation }) => runHandlerWithTimeout(tabId, operation)); |
| 89 | return result; |
| 90 | } |