1import type { PreviewViewportSetting } from "@t3tools/contracts";
2
3type BrowserViewportHandler = (setting: PreviewViewportSetting) => Promise<void>;
4
5export const BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS = 15_000;
6
7class 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
15const handlers = new Map<string, BrowserViewportHandler>();
16const commitTails = new Map<string, Promise<void>>();
17
18const 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 */
46export function runBrowserViewportMutation<A>(
47 tabId: string,
48 mutation: () => Promise<A>,
49): Promise<A> {
50 return queueBrowserViewportMutation(tabId, mutation).execution;
51}
52
53const 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
66export 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
76export 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}