| 1 | import type { PreviewAutomationControlReason } from "@t3tools/contracts"; |
| 2 | |
| 3 | export 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. */ |
| 15 | export 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 | } |