1import type { PreviewAutomationControlReason } from "@t3tools/contracts";
2
3export class BrowserControlInterrupted extends Error {
4 readonly reason: PreviewAutomationControlReason;
5 constructor(
6 message = "Browser control changed. Refresh the snapshot before trying again.",
7 reason: PreviewAutomationControlReason = "interrupted",
8 ) {
9 super(message);
10 this.reason = reason;
11 }
12}
13
14/** Reserves control immediately, but drains running work before the new owner can act. */
15export class SessionControl {
16 readonly agentId: string | null;
17 private readonly onGenerationChange: () => void;
18 private tail: Promise<unknown> = Promise.resolve();
19 private pending: Promise<void> = Promise.resolve();
20 private owner: string | null = null;
21 private epoch = 0;
22 private closed = false;
23
24 constructor(agentId: string | null, onGenerationChange: () => void = () => {}) {
25 this.agentId = agentId;
26 this.onGenerationChange = onGenerationChange;
27 }
28
29 get controller() {
30 return this.owner;
31 }
32
33 get generation() {
34 return this.epoch;
35 }
36
37 private enqueue<A>(run: () => Promise<A>): Promise<A> {
38 const result = this.tail.then(run);
39 this.tail = result.then(
40 () => this.pending,
41 () => this.pending,
42 );
43 return result;
44 }
45
46 /** An action can respond before its navigation commits, while later actions still wait. */
47 track(work: Promise<unknown>) {
48 const settled = work.then(
49 () => undefined,
50 () => undefined,
51 );
52 this.pending = Promise.all([this.pending, settled]).then(() => undefined);
53 }
54
55 private assertOpen() {
56 if (this.closed) throw new BrowserControlInterrupted("This browser tab is closed.", "closed");
57 }
58
59 private changeOwner(owner: string | null) {
60 this.owner = owner;
61 this.epoch++;
62 this.onGenerationChange();
63 }
64
65 private async action<A>(allowed: () => boolean, run: () => Promise<A>) {
66 this.assertOpen();
67 if (!allowed()) throw new BrowserControlInterrupted("You do not control this browser tab.");
68 const epoch = this.epoch;
69 return this.enqueue(async () => {
70 this.assertOpen();
71 if (epoch !== this.epoch || !allowed()) throw new BrowserControlInterrupted();
72 return run();
73 });
74 }
75
76 /** Whether an agent may act here: its own tab, or a tab no agent opened. */
77 agentMayAct(agentId: string) {
78 return this.agentId === null || this.agentId === agentId;
79 }
80
81 /**
82 * An agent action. It may act on its own tab or on one a human opened, but
83 * never while a human controls the tab; taking control interrupts it.
84 */
85 agent<A>(agentId: string, run: () => Promise<A>) {
86 if (!this.agentMayAct(agentId))
87 return Promise.reject(
88 new BrowserControlInterrupted("This tab belongs to another agent.", "agentMismatch"),
89 );
90 if (this.owner !== null)
91 return Promise.reject(
92 new BrowserControlInterrupted("A human controls this tab.", "humanControl"),
93 );
94 return this.action(() => this.agentMayAct(agentId) && this.owner === null, run);
95 }
96
97 /** Whether this agent's action would run now rather than be refused. */
98 agentCanActNow(agentId: string) {
99 return this.agentMayAct(agentId) && this.owner === null;
100 }
101
102 /**
103 * A read that needs no control and does not queue, so an agent can look at
104 * a page while a human drives it without holding up the human's input.
105 */
106 observe<A>(run: () => Promise<A>) {
107 this.assertOpen();
108 return run();
109 }
110
111 /**
112 * Work any client may ask for, such as a viewport or appearance change. It
113 * waits its turn behind running actions but needs no control.
114 */
115 system<A>(run: () => Promise<A>) {
116 this.assertOpen();
117 return this.enqueue(async () => {
118 this.assertOpen();
119 return run();
120 });
121 }
122
123 human<A>(viewerId: string, run: () => Promise<A>) {
124 return this.action(() => this.owner === viewerId, run);
125 }
126
127 async take(viewerId: string) {
128 this.assertOpen();
129 if (this.owner !== null && this.owner !== viewerId) {
130 throw new BrowserControlInterrupted("Another viewer controls this browser tab.");
131 }
132 if (this.owner !== viewerId) this.changeOwner(viewerId);
133 return this.human(viewerId, async () => {});
134 }
135
136 async release(viewerId: string, afterDrain: () => Promise<void> = async () => {}) {
137 this.assertOpen();
138 if (this.owner !== viewerId) {
139 throw new BrowserControlInterrupted(
140 "Only the controlling viewer can release this browser tab.",
141 );
142 }
143 this.changeOwner(null);
144 await this.enqueue(afterDrain);
145 }
146
147 async disconnect(viewerId: string, afterDrain?: () => Promise<void>) {
148 if (!this.closed && this.owner === viewerId) await this.release(viewerId, afterDrain);
149 }
150
151 async close() {
152 if (!this.closed) {
153 this.closed = true;
154 this.changeOwner(null);
155 }
156 await this.tail;
157 }
158}