effect-browser 0.2.0-beta.9 → 0.3.0-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/README.md +861 -1161
  2. package/dist/Browser-B4qncxe7.mjs +628 -0
  3. package/dist/Browser-B4qncxe7.mjs.map +1 -0
  4. package/dist/Browser.d.mts +129 -3
  5. package/dist/Browser.mjs +2 -22
  6. package/dist/BrowserError-Coy7EW1N.d.mts +140 -0
  7. package/dist/BrowserError.d.mts +2 -0
  8. package/dist/BrowserError.mjs +211 -0
  9. package/dist/BrowserError.mjs.map +1 -0
  10. package/dist/BrowserEvent-BRu2juEz.d.mts +279 -0
  11. package/dist/BrowserEvent.d.mts +2 -0
  12. package/dist/BrowserEvent.mjs +297 -0
  13. package/dist/BrowserEvent.mjs.map +1 -0
  14. package/dist/Cdp.d.mts +32 -0
  15. package/dist/Cdp.mjs +61 -0
  16. package/dist/Cdp.mjs.map +1 -0
  17. package/dist/Change-DNvztNLu.mjs +138 -0
  18. package/dist/Change-DNvztNLu.mjs.map +1 -0
  19. package/dist/Change-iMlcuzVD.d.mts +71 -0
  20. package/dist/Change.d.mts +2 -0
  21. package/dist/Change.mjs +2 -0
  22. package/dist/Chromium.d.mts +29 -81
  23. package/dist/Chromium.mjs +57 -392
  24. package/dist/Chromium.mjs.map +1 -1
  25. package/dist/Expect.d.mts +62 -0
  26. package/dist/Expect.mjs +114 -0
  27. package/dist/Expect.mjs.map +1 -0
  28. package/dist/Frame-Di2PxyF7.d.mts +119 -0
  29. package/dist/Frame.d.mts +2 -0
  30. package/dist/Frame.mjs +125 -0
  31. package/dist/Frame.mjs.map +1 -0
  32. package/dist/Moment.d.mts +2 -0
  33. package/dist/Moment.mjs +288 -0
  34. package/dist/Moment.mjs.map +1 -0
  35. package/dist/Motion.d.mts +40 -0
  36. package/dist/Motion.mjs +122 -0
  37. package/dist/Motion.mjs.map +1 -0
  38. package/dist/Page-B5cjaUp5.mjs +618 -0
  39. package/dist/Page-B5cjaUp5.mjs.map +1 -0
  40. package/dist/Page-B7X6DQUn.d.mts +595 -0
  41. package/dist/Page.d.mts +2 -0
  42. package/dist/Page.mjs +2 -0
  43. package/dist/Plan-Akcac63g.d.mts +209 -0
  44. package/dist/Plan-D1wrJVs8.mjs +508 -0
  45. package/dist/Plan-D1wrJVs8.mjs.map +1 -0
  46. package/dist/Plan.d.mts +2 -3
  47. package/dist/Plan.mjs +2 -2
  48. package/dist/Policy.d.mts +76 -0
  49. package/dist/Policy.mjs +237 -0
  50. package/dist/Policy.mjs.map +1 -0
  51. package/dist/Presentation.d.mts +48 -0
  52. package/dist/Presentation.mjs +117 -0
  53. package/dist/Presentation.mjs.map +1 -0
  54. package/dist/Snapshot-B34hZ4Jl.d.mts +68 -0
  55. package/dist/Snapshot.d.mts +2 -0
  56. package/dist/Snapshot.mjs +79 -0
  57. package/dist/Snapshot.mjs.map +1 -0
  58. package/dist/Stage.d.mts +64 -0
  59. package/dist/Stage.mjs +237 -0
  60. package/dist/Stage.mjs.map +1 -0
  61. package/dist/Supervisor-CYWFK3vM.d.mts +280 -0
  62. package/dist/Supervisor-TNKNOVzu.mjs +599 -0
  63. package/dist/Supervisor-TNKNOVzu.mjs.map +1 -0
  64. package/dist/Supervisor.d.mts +2 -0
  65. package/dist/Supervisor.mjs +2 -0
  66. package/dist/context-B6xr8nVi.mjs +261 -0
  67. package/dist/context-B6xr8nVi.mjs.map +1 -0
  68. package/dist/index.d.mts +17 -14
  69. package/dist/index.mjs +18 -13
  70. package/dist/match.inpage-xOe9LNO0.mjs +48 -0
  71. package/dist/match.inpage-xOe9LNO0.mjs.map +1 -0
  72. package/dist/page-BAf__WKX.mjs +5075 -0
  73. package/dist/page-BAf__WKX.mjs.map +1 -0
  74. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  75. package/dist/url-Cd04Okid.mjs +264 -0
  76. package/dist/url-Cd04Okid.mjs.map +1 -0
  77. package/package.json +96 -61
  78. package/src/Browser.ts +672 -0
  79. package/src/BrowserError.ts +234 -0
  80. package/src/BrowserEvent.ts +296 -0
  81. package/src/Cdp.ts +101 -0
  82. package/src/Change.ts +88 -0
  83. package/src/Chromium.ts +102 -0
  84. package/src/Expect.ts +180 -0
  85. package/src/Frame.ts +132 -0
  86. package/src/Moment.ts +479 -0
  87. package/src/Motion.ts +166 -0
  88. package/src/Page.ts +522 -0
  89. package/src/Plan.ts +663 -0
  90. package/src/Policy.ts +325 -0
  91. package/src/Presentation.ts +195 -0
  92. package/src/Snapshot.ts +89 -0
  93. package/src/Stage.ts +467 -0
  94. package/src/Supervisor.ts +599 -0
  95. package/src/index.ts +20 -0
  96. package/src/internal/input/actions.ts +610 -0
  97. package/src/internal/input/dispatch.ts +161 -0
  98. package/src/internal/input/edit.inpage.ts +162 -0
  99. package/src/internal/input/evidence.inpage.ts +164 -0
  100. package/src/internal/input/gate.ts +224 -0
  101. package/src/internal/input/guard.inpage.ts +605 -0
  102. package/src/internal/input/guard.ts +184 -0
  103. package/src/internal/input/keyboard.ts +112 -0
  104. package/src/internal/input/keys.ts +127 -0
  105. package/src/internal/input/perform.ts +376 -0
  106. package/src/internal/input/pointer.ts +153 -0
  107. package/src/internal/input/replies.ts +195 -0
  108. package/src/internal/input/settle.ts +50 -0
  109. package/src/internal/input/style.ts +186 -0
  110. package/src/internal/input/targets.inpage.ts +293 -0
  111. package/src/internal/input/targets.ts +148 -0
  112. package/src/internal/page/bridge.ts +391 -0
  113. package/src/internal/page/context.ts +314 -0
  114. package/src/internal/page/identity.ts +35 -0
  115. package/src/internal/page/lane.ts +311 -0
  116. package/src/internal/page/navigation.ts +183 -0
  117. package/src/internal/page/openings.ts +87 -0
  118. package/src/internal/page/page.ts +165 -0
  119. package/src/internal/page/url.ts +100 -0
  120. package/src/internal/page/viewport.ts +43 -0
  121. package/src/internal/pictures/capture.ts +776 -0
  122. package/src/internal/pictures/clock.ts +141 -0
  123. package/src/internal/pictures/counts.ts +92 -0
  124. package/src/internal/pictures/pictures.ts +521 -0
  125. package/src/internal/pictures/transport.ts +243 -0
  126. package/src/internal/reading/choose.ts +100 -0
  127. package/src/internal/reading/context.inpage.ts +201 -0
  128. package/src/internal/reading/controls.inpage.ts +158 -0
  129. package/src/internal/reading/expect.inpage.ts +70 -0
  130. package/src/internal/reading/match.inpage.ts +92 -0
  131. package/src/internal/reading/names.inpage.ts +306 -0
  132. package/src/internal/reading/outline.inpage.ts +260 -0
  133. package/src/internal/reading/reading.ts +223 -0
  134. package/src/internal/reading/ready.inpage.ts +164 -0
  135. package/src/internal/reading/ready.ts +111 -0
  136. package/src/internal/reading/subjects.inpage.ts +188 -0
  137. package/src/internal/reading/text.inpage.ts +135 -0
  138. package/src/internal/reading/walk.inpage.ts +220 -0
  139. package/src/internal/supervisor/lifecycle.ts +416 -0
  140. package/src/internal/timeline/changes.inpage.ts +216 -0
  141. package/src/internal/timeline/changes.ts +100 -0
  142. package/src/internal/timeline/events.ts +94 -0
  143. package/src/internal/timeline/fold.inpage.ts +123 -0
  144. package/src/internal/timeline/history.inpage.ts +229 -0
  145. package/src/internal/timeline/marks.inpage.ts +207 -0
  146. package/src/internal/timeline/record.inpage.ts +133 -0
  147. package/src/internal/timeline/sight.inpage.ts +122 -0
  148. package/src/internal/timeline/window.ts +96 -0
  149. package/src/internal/timeline/words.ts +65 -0
  150. package/dist/Bootstrap-B9UUXsvF.d.mts +0 -183
  151. package/dist/Bootstrap-CeQOWBy6.mjs +0 -189
  152. package/dist/Bootstrap-CeQOWBy6.mjs.map +0 -1
  153. package/dist/Bootstrap.d.mts +0 -3
  154. package/dist/Bootstrap.mjs +0 -2
  155. package/dist/Browser-BF9_sORP.d.mts +0 -388
  156. package/dist/Browser.mjs.map +0 -1
  157. package/dist/BrowserData-CPIIIHol.d.mts +0 -585
  158. package/dist/BrowserData.d.mts +0 -3
  159. package/dist/BrowserData.mjs +0 -695
  160. package/dist/BrowserData.mjs.map +0 -1
  161. package/dist/BrowserRuntime-B6nT1G4X.d.mts +0 -148
  162. package/dist/BrowserRuntime-C1r2aSRK.mjs +0 -6820
  163. package/dist/BrowserRuntime-C1r2aSRK.mjs.map +0 -1
  164. package/dist/BrowserRuntime.d.mts +0 -3
  165. package/dist/BrowserRuntime.mjs +0 -3
  166. package/dist/Capture.d.mts +0 -56
  167. package/dist/Capture.mjs +0 -678
  168. package/dist/Capture.mjs.map +0 -1
  169. package/dist/CaptureData-CYriZHX1.mjs +0 -201
  170. package/dist/CaptureData-CYriZHX1.mjs.map +0 -1
  171. package/dist/CaptureData.d.mts +0 -219
  172. package/dist/CaptureData.mjs +0 -2
  173. package/dist/Errors.d.mts +0 -158
  174. package/dist/Errors.mjs +0 -207
  175. package/dist/Errors.mjs.map +0 -1
  176. package/dist/Images-yCG9BmRU.mjs +0 -76
  177. package/dist/Images-yCG9BmRU.mjs.map +0 -1
  178. package/dist/OperationOptions-DAeRS6O9.mjs +0 -55
  179. package/dist/OperationOptions-DAeRS6O9.mjs.map +0 -1
  180. package/dist/PageControl.d.mts +0 -15
  181. package/dist/PageControl.mjs +0 -34
  182. package/dist/PageControl.mjs.map +0 -1
  183. package/dist/PageRegistry-B3q9DS3g.mjs +0 -164
  184. package/dist/PageRegistry-B3q9DS3g.mjs.map +0 -1
  185. package/dist/Plan-Dyxu2q8C.mjs +0 -543
  186. package/dist/Plan-Dyxu2q8C.mjs.map +0 -1
  187. package/dist/PlanData-B668RxbQ.d.mts +0 -4831
  188. package/dist/PlanData.d.mts +0 -3
  189. package/dist/PlanData.mjs +0 -268
  190. package/dist/PlanData.mjs.map +0 -1
  191. package/dist/PlanInput-BJcPPncs.mjs +0 -75
  192. package/dist/PlanInput-BJcPPncs.mjs.map +0 -1
  193. package/dist/PublicSession-DCSMMqOj.mjs +0 -5602
  194. package/dist/PublicSession-DCSMMqOj.mjs.map +0 -1
  195. package/dist/Testing.d.mts +0 -479
  196. package/dist/Testing.mjs +0 -2147
  197. package/dist/Testing.mjs.map +0 -1
  198. package/dist/Timeline-fV22Xk-S.d.mts +0 -29
  199. package/dist/Timeline.d.mts +0 -4
  200. package/dist/Timeline.mjs +0 -20
  201. package/dist/Timeline.mjs.map +0 -1
  202. package/dist/TimelineData-BsOn-YGZ.d.mts +0 -3818
  203. package/dist/TimelineData.d.mts +0 -3
  204. package/dist/TimelineData.mjs +0 -482
  205. package/dist/TimelineData.mjs.map +0 -1
package/src/Browser.ts ADDED
@@ -0,0 +1,672 @@
1
+ /**
2
+ * A live browser: its pages and what happens in it.
3
+ *
4
+ * `Browser` is a service with one implementation, built by a provider over a Playwright browser
5
+ * context: `Chromium` launches one locally, `Cdp` connects to any DevTools endpoint and
6
+ * `effect-browserbase` allocates a hosted one. Code written against `Browser` runs on all three.
7
+ * A page is named by its CDP target id, so a new connection to the same browser finds it again.
8
+ *
9
+ * @since 0.3.0
10
+ */
11
+ import {
12
+ Clock,
13
+ Context,
14
+ DateTime,
15
+ Deferred,
16
+ Duration,
17
+ Effect,
18
+ Exit,
19
+ Option,
20
+ Queue,
21
+ Schema,
22
+ Scope,
23
+ Semaphore,
24
+ type Stream,
25
+ } from "effect";
26
+ import type { BrowserContext, Dialog, Page as PlaywrightPage } from "playwright-core";
27
+
28
+ import { type BrowserError, Failed, InvalidRequest, Limit } from "./BrowserError.ts";
29
+ import {
30
+ type BrowserEvent,
31
+ DialogShown,
32
+ Disconnected,
33
+ type DisconnectCause,
34
+ PageClosed,
35
+ PageOpened,
36
+ PageUntracked,
37
+ type RecordedEvent,
38
+ SessionEnding,
39
+ } from "./BrowserEvent.ts";
40
+ import * as Gate from "./internal/input/gate.ts";
41
+ import { call, failWith, type Settings, undispatched } from "./internal/page/context.ts";
42
+ import * as Identity from "./internal/page/identity.ts";
43
+ import { FailFast } from "./internal/page/lane.ts";
44
+ import * as Openings from "./internal/page/openings.ts";
45
+ import * as PageImpl from "./internal/page/page.ts";
46
+ import * as Url from "./internal/page/url.ts";
47
+ import * as BrowserClock from "./internal/pictures/clock.ts";
48
+ import * as Timeline from "./internal/timeline/events.ts";
49
+ import type * as Page from "./Page.ts";
50
+
51
+ /** A script that documents run before their own, such as a consent-banner remover. */
52
+ export interface InitScript {
53
+ /**
54
+ * The documents that run it, by a pattern their address (`location.href`) matches; every
55
+ * document and frame when left out. Under a `match`, the source runs as a block, so its own
56
+ * top-level `let`, `const` and classes stay its own.
57
+ */
58
+ readonly match?: RegExp | undefined;
59
+ readonly source: string;
60
+ }
61
+
62
+ export interface Options {
63
+ /**
64
+ * Bound on each action; finite and positive. Defaults to 10 seconds. What a presenter's view
65
+ * takes to show an action, such as its glides and typing at a person's pace, does not count
66
+ * against it.
67
+ */
68
+ readonly actionTimeout?: Duration.Input | undefined;
69
+ /** Bound on each navigation; finite and positive. Defaults to 30 seconds. */
70
+ readonly navigationTimeout?: Duration.Input | undefined;
71
+ /**
72
+ * How long each page keeps screencast frames for `recentFrames`, measured back from the newest;
73
+ * finite and positive. Defaults to 5 seconds, a moment's default window.
74
+ */
75
+ readonly frameHistory?: Duration.Input | undefined;
76
+ /** Events retained for replay and `recentEvents`. A positive safe integer, defaulting to 4,096. */
77
+ readonly eventHistory?: number | undefined;
78
+ /** Allow, deny or hold each input or navigation before it reaches the page. Defaults to allow. */
79
+ readonly guard?: Page.InputGuard | undefined;
80
+ /** A separate bound for policy holds, outside action timeouts. Defaults to 5 minutes. */
81
+ readonly policyTimeout?: Duration.Input | undefined;
82
+ /** Scripts that new documents run before their own, each where its `match` allows. */
83
+ readonly initScripts?: ReadonlyArray<InitScript> | undefined;
84
+ /**
85
+ * The most pages the browser keeps open, those a site opens included, which are never refused. At
86
+ * it, `newPage` waits for one to close, within the action timeout, then fails `Limit`; under
87
+ * `Page.failFast` it fails at once. A positive safe integer; no limit when left out.
88
+ */
89
+ readonly maxPages?: number | undefined;
90
+ }
91
+
92
+ export interface EventOptions {
93
+ /** Resume after this sequence; 0 replays from the beginning if it is still retained. */
94
+ readonly after?: number | undefined;
95
+ }
96
+
97
+ export interface SuspendOptions {
98
+ /** Finite, nonnegative drain bound; defaults to the browser's navigationTimeout. */
99
+ readonly drain?: Duration.Input | undefined;
100
+ }
101
+
102
+ /** When admitted input finished; events after `since` describe the handoff interval. */
103
+ export class Suspension extends Schema.Class<Suspension>("effect-browser/Suspension")({
104
+ since: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
105
+ at: Schema.Finite,
106
+ }) {}
107
+
108
+ /**
109
+ * A connection to the browser apart from the one that drives its pages, for their screencasts,
110
+ * as a provider that reaches the browser over a network supplies it: a frame and its
111
+ * acknowledgement then never wait behind a large message, such as an upload or a read's answer,
112
+ * on the connection that drives the page. It is read-only. A page's session on it sends the
113
+ * capture's commands alone, and the page's own session keeps its focus emulation, which keeps a
114
+ * tab behind painting.
115
+ */
116
+ export interface CaptureSource {
117
+ /**
118
+ * A session on the page's target, for the scope. `event` gets the session's events in the order
119
+ * the browser sent them, its target's detach among them, until the scope closes; `lost` gets
120
+ * the connection's failure, once, if it fails first. Its calls never throw: a failure rejects.
121
+ */
122
+ readonly attach: (
123
+ target: string,
124
+ listener: {
125
+ readonly event: (method: string, params: unknown) => void;
126
+ readonly lost: (error: BrowserError) => void;
127
+ },
128
+ ) => Effect.Effect<
129
+ (
130
+ method:
131
+ | "Page.enable"
132
+ | "Page.getFrameTree"
133
+ | "Page.startScreencast"
134
+ | "Page.screencastFrameAck"
135
+ | "Page.stopScreencast",
136
+ params?: Record<string, unknown>,
137
+ ) => Promise<unknown>,
138
+ BrowserError,
139
+ Scope.Scope
140
+ >;
141
+ }
142
+
143
+ export interface Service {
144
+ /** The provider's session id, or a local id. */
145
+ readonly id: string;
146
+ /** Fresh for this connection, even when reattaching the same provider session and page. */
147
+ readonly connection: string;
148
+ readonly provider: string;
149
+ /** When the provider ends the session on its own, as a hosted session's timeout does. */
150
+ readonly expiresAt: DateTime.Utc | undefined;
151
+ /** Host monotonic milliseconds on the clock shared by this browser’s events and frames. */
152
+ readonly now: Effect.Effect<number>;
153
+ /** Drain admitted input and withhold new input for this scope; reads and capture continue. */
154
+ readonly suspend: (
155
+ options?: SuspendOptions,
156
+ ) => Effect.Effect<Suspension, BrowserError, Scope.Scope>;
157
+ /** The Playwright context, for anything this API does not cover. Never give it to a model. */
158
+ readonly context: BrowserContext;
159
+ /** Open pages in the order this browser began tracking them. */
160
+ readonly pages: Effect.Effect<ReadonlyArray<Page.Page>>;
161
+ /** The open page with this id, its CDP target id, as stored before a reconnect. */
162
+ readonly page: (id: string) => Effect.Effect<Option.Option<Page.Page>>;
163
+ /** The first open page, opening one when there is none. */
164
+ readonly firstPage: Effect.Effect<Page.Page, BrowserError>;
165
+ readonly newPage: (url?: string) => Effect.Effect<Page.Page, BrowserError>;
166
+ /**
167
+ * Completes once the browser is lost to its owner, with why: its connection dropped, its
168
+ * provider ended its session, or its owner's scope released it.
169
+ */
170
+ readonly disconnected: Effect.Effect<DisconnectCause>;
171
+ /** Live events, or replay after a cursor. A reader that falls behind fails with EventHistoryExpired. */
172
+ readonly events: (options?: EventOptions) => Stream.Stream<RecordedEvent, BrowserError>;
173
+ /** The latest `eventHistory` events, oldest first. */
174
+ readonly recentEvents: Effect.Effect<ReadonlyArray<BrowserEvent>>;
175
+ }
176
+
177
+ export class Browser extends Context.Service<Browser, Service>()("effect-browser/Browser") {}
178
+
179
+ /** The settings every page shares, and how many events the browser keeps, from its options. */
180
+ const settingsOf = Effect.fnUntraced(function* (options: Options) {
181
+ const invalid = (detail: string) => undispatched("make", new InvalidRequest({ detail }));
182
+
183
+ // Every bound guards a lock or a held key, so each must be a real, finite deadline.
184
+ const bound = (name: string, input: Duration.Input | undefined, fallback: Duration.Duration) => {
185
+ const decoded = Duration.fromInput(input ?? fallback);
186
+
187
+ return Option.isSome(decoded) &&
188
+ Number.isFinite(Duration.toMillis(decoded.value)) &&
189
+ Duration.isPositive(decoded.value)
190
+ ? Effect.succeed(decoded.value)
191
+ : Effect.fail(invalid(`${name} must be finite and greater than zero`));
192
+ };
193
+
194
+ const settings: Settings = {
195
+ actionTimeout: yield* bound("actionTimeout", options.actionTimeout, Duration.seconds(10)),
196
+ navigationTimeout: yield* bound(
197
+ "navigationTimeout",
198
+ options.navigationTimeout,
199
+ Duration.seconds(30),
200
+ ),
201
+ frameHistory: yield* bound("frameHistory", options.frameHistory, Duration.seconds(5)),
202
+ guard: options.guard,
203
+ policyTimeout: yield* bound("policyTimeout", options.policyTimeout, Duration.minutes(5)),
204
+ };
205
+
206
+ const eventHistory = options.eventHistory ?? 4096;
207
+ const { maxPages } = options;
208
+
209
+ if (!Number.isSafeInteger(eventHistory) || eventHistory < 1)
210
+ return yield* invalid("eventHistory must be a positive safe integer");
211
+ if (maxPages !== undefined && (!Number.isSafeInteger(maxPages) || maxPages < 1))
212
+ return yield* invalid("maxPages must be a positive safe integer");
213
+
214
+ return { settings, eventHistory, maxPages };
215
+ });
216
+
217
+ /**
218
+ * A provider that ends a session at `expiresAt` by its own clock can cut the connection a little
219
+ * before this host's clock gets there.
220
+ */
221
+ const endMargin = Duration.seconds(5);
222
+
223
+ /**
224
+ * The browser's loss, once: its context closing from the other side, at or after the session's
225
+ * end or before it, or `lose`, as the owner's scope releases it. Playwright leaves the calls in
226
+ * flight on the library's own sessions unanswered when the connection drops, so `untilLost` fails
227
+ * each with the browser, as a closed page's call fails.
228
+ */
229
+ const watchLoss = Effect.fnUntraced(function* (
230
+ context: BrowserContext,
231
+ expiresAt: DateTime.Utc | undefined,
232
+ clock: Clock.Clock,
233
+ disconnected: (cause: DisconnectCause) => void,
234
+ ) {
235
+ let lostBy: DisconnectCause | undefined;
236
+ const lost = yield* Deferred.make<DisconnectCause>();
237
+ const inFlight = new Set<(cause: Error) => void>();
238
+ const gone = () => new Error("Target page, context or browser has been closed");
239
+
240
+ const lose = (cause: DisconnectCause) => {
241
+ if (lostBy !== undefined) return;
242
+ lostBy = cause;
243
+ for (const reject of inFlight) reject(gone());
244
+ disconnected(cause);
245
+ Deferred.doneUnsafe(lost, Exit.succeed(cause));
246
+ };
247
+
248
+ const untilLost = <A>(reply: Promise<A>): Promise<A> => {
249
+ if (lostBy !== undefined) {
250
+ void reply.catch(() => undefined);
251
+
252
+ return Promise.reject(gone());
253
+ }
254
+
255
+ return new Promise<A>((resolve, reject) => {
256
+ inFlight.add(reject);
257
+ void reply.then(resolve, reject).finally(() => inFlight.delete(reject));
258
+ });
259
+ };
260
+
261
+ const ended = () =>
262
+ expiresAt !== undefined &&
263
+ clock.currentTimeMillisUnsafe() >=
264
+ DateTime.toEpochMillis(expiresAt) - Duration.toMillis(endMargin);
265
+
266
+ const onClose = () => lose(ended() ? "session" : "connection");
267
+
268
+ context.on("close", onClose);
269
+ yield* Effect.addFinalizer(() => Effect.sync(() => context.off("close", onClose)));
270
+ if (context.browser()?.isConnected() === false) onClose();
271
+
272
+ const native = <A>(operation: string, run: () => Promise<A>) =>
273
+ call(operation, run, () => lostBy ?? "page");
274
+
275
+ return { lostBy: () => lostBy, lost: Deferred.await(lost), lose, untilLost, native };
276
+ });
277
+
278
+ /**
279
+ * What a page's own Playwright events publish, its close and crash aside, while its scope is
280
+ * open. Dialogs are answered: alerts accepted, others dismissed. The page's own session tells its
281
+ * documents' commits and loads.
282
+ */
283
+ const listen = (
284
+ playwright: PlaywrightPage,
285
+ page: string,
286
+ publish: (event: BrowserEvent) => number,
287
+ now: () => number,
288
+ on: { readonly close: () => void; readonly crash: () => void },
289
+ ) => {
290
+ const dialog = (shown: Dialog) => {
291
+ const kind = shown.type();
292
+ const answer = kind === "alert" || kind === "beforeunload" ? "accepted" : "dismissed";
293
+ const message = shown.message().slice(0, 500);
294
+
295
+ publish(new DialogShown({ at: now(), page, kind, message, answer }));
296
+ (answer === "accepted" ? shown.accept() : shown.dismiss()).catch(() => undefined);
297
+ };
298
+
299
+ return Effect.acquireRelease(
300
+ Effect.sync(() => {
301
+ playwright.on("close", on.close);
302
+ playwright.on("crash", on.crash);
303
+ playwright.on("dialog", dialog);
304
+ }),
305
+ () =>
306
+ Effect.sync(() => {
307
+ playwright.off("close", on.close);
308
+ playwright.off("crash", on.crash);
309
+ playwright.off("dialog", dialog);
310
+ }),
311
+ );
312
+ };
313
+
314
+ /**
315
+ * Register each page the site opens, such as a popup, as Playwright reports it, for as long as the
316
+ * scope is open. One that could not be tracked is published, unless it closed as it opened.
317
+ */
318
+ const followOpened = Effect.fnUntraced(function* (
319
+ context: BrowserContext,
320
+ register: (playwright: PlaywrightPage) => Effect.Effect<Page.Page, BrowserError>,
321
+ publish: (event: BrowserEvent) => number,
322
+ now: () => number,
323
+ registrations: Openings.Registrations,
324
+ ) {
325
+ const opened = yield* Queue.unbounded<PlaywrightPage>();
326
+
327
+ const onPage = (playwright: PlaywrightPage) => {
328
+ registrations.set(playwright, Deferred.makeUnsafe<void, BrowserError>());
329
+ Queue.offerUnsafe(opened, playwright);
330
+ };
331
+
332
+ const untracked = (playwright: PlaywrightPage) => (error: BrowserError) =>
333
+ Effect.sync(() => {
334
+ const url = Url.redact(playwright.url());
335
+
336
+ if (!playwright.isClosed())
337
+ publish(new PageUntracked({ at: now(), url, detail: error.message }));
338
+ });
339
+
340
+ context.on("page", onPage);
341
+ yield* Effect.addFinalizer(() => Effect.sync(() => context.off("page", onPage)));
342
+ yield* Queue.take(opened).pipe(
343
+ Effect.flatMap((playwright) =>
344
+ register(playwright).pipe(
345
+ Effect.asVoid,
346
+ Effect.onExit((exit) =>
347
+ Effect.sync(() => {
348
+ const done = registrations.get(playwright);
349
+
350
+ if (done !== undefined) Deferred.doneUnsafe(done, exit);
351
+ }),
352
+ ),
353
+ Effect.catch(untracked(playwright)),
354
+ ),
355
+ ),
356
+ Effect.forever,
357
+ Effect.forkScoped,
358
+ );
359
+ });
360
+
361
+ /** Release something native, on the owner's clock, giving up after a second: cleanup only. */
362
+ const releaseNative = (clock: Clock.Clock) => (run: () => Promise<unknown>) =>
363
+ Effect.tryPromise(run).pipe(
364
+ Effect.interruptible,
365
+ Effect.timeoutOrElse({ duration: Duration.seconds(1), orElse: () => Effect.void }),
366
+ Effect.provideService(Clock.Clock, clock),
367
+ Effect.ignore,
368
+ );
369
+
370
+ /** A page's own events among the browser's retained ones, and the openings of tabs it opened. */
371
+ const eventsOf = (recent: Effect.Effect<ReadonlyArray<RecordedEvent>>, page: string) =>
372
+ Effect.map(recent, (records) =>
373
+ records.flatMap(({ event }) =>
374
+ ("page" in event && event.page === page) ||
375
+ (event._tag === "PageOpened" && event.opener === page)
376
+ ? [event]
377
+ : [],
378
+ ),
379
+ );
380
+
381
+ /** A script as its documents run it: everywhere, or as a block where its `match` allows. */
382
+ const scoped = ({ match, source }: InitScript) =>
383
+ match === undefined ? source : `if (${String(match)}.test(location.href)) {\n${source}\n}`;
384
+
385
+ /**
386
+ * At most `maxPages` open pages, those a site opened included, which are never refused: an open
387
+ * past it waits for one to close, within `wait` on the owner's clock, then fails `Limit`, or fails
388
+ * at once under `FailFast`. An open counts until its page is tracked.
389
+ */
390
+ const pageBudget = (
391
+ maxPages: number | undefined,
392
+ tracked: () => number,
393
+ wait: Duration.Duration,
394
+ clock: Clock.Clock,
395
+ ) => {
396
+ let opening = 0;
397
+ // Completed as a page closes or an open ends, so the opens waiting look again.
398
+ let roomMade = Deferred.makeUnsafe<void>();
399
+
400
+ const makeRoom = () => {
401
+ Deferred.doneUnsafe(roomMade, Exit.void);
402
+ roomMade = Deferred.makeUnsafe<void>();
403
+ };
404
+
405
+ const full = (limit: number) => tracked() + opening >= limit;
406
+
407
+ const room = (limit: number) =>
408
+ Effect.gen(function* () {
409
+ const refused = undispatched("newPage", new Limit({ maxPages: limit }));
410
+
411
+ if ((yield* FailFast) && full(limit)) return yield* refused;
412
+ yield* Effect.gen(function* () {
413
+ while (full(limit)) yield* Deferred.await(roomMade);
414
+ opening += 1;
415
+ }).pipe(
416
+ Effect.timeoutOrElse({ duration: wait, orElse: () => Effect.fail(refused) }),
417
+ Effect.provideService(Clock.Clock, clock),
418
+ );
419
+ });
420
+
421
+ const open = <A, E, R>(opened: Effect.Effect<A, E, R>) =>
422
+ maxPages === undefined
423
+ ? opened
424
+ : Effect.uninterruptibleMask((restore) =>
425
+ restore(room(maxPages)).pipe(
426
+ Effect.andThen(
427
+ restore(opened).pipe(
428
+ Effect.ensuring(
429
+ Effect.sync(() => {
430
+ opening -= 1;
431
+ makeRoom();
432
+ }),
433
+ ),
434
+ ),
435
+ ),
436
+ ),
437
+ );
438
+
439
+ return { makeRoom, open };
440
+ };
441
+
442
+ /** A call sent now and awaited later, by whatever needs its answer. */
443
+ const sentNow = <A>(sent: Promise<A>) => {
444
+ void sent.catch(() => undefined);
445
+
446
+ return sent;
447
+ };
448
+
449
+ /** Close tab resources after publishing their close; a browser loss speaks for all its tabs. */
450
+ const closures = (
451
+ lostBy: () => DisconnectCause | undefined,
452
+ publish: (event: BrowserEvent) => number,
453
+ now: () => number,
454
+ ) =>
455
+ Effect.gen(function* () {
456
+ const closing = yield* Queue.unbounded<{
457
+ readonly scope: Scope.Closeable;
458
+ readonly page: string;
459
+ readonly cause: PageClosed["cause"];
460
+ }>();
461
+
462
+ yield* Queue.take(closing).pipe(
463
+ Effect.tap(({ page, cause }) =>
464
+ Effect.sync(() => {
465
+ if (lostBy() === undefined) publish(new PageClosed({ at: now(), page, cause }));
466
+ }),
467
+ ),
468
+ Effect.flatMap(({ scope }) => Scope.close(scope, Exit.void)),
469
+ Effect.forever,
470
+ Effect.forkScoped,
471
+ );
472
+
473
+ return closing;
474
+ });
475
+
476
+ /**
477
+ * Build the service over a context the provider owns. The provider closes the context; this only
478
+ * tracks its pages and events, for as long as the surrounding scope is open. Each page's protocol
479
+ * session, capture and listeners end when that page closes or when the scope does, so a closed
480
+ * tab retains nothing and the context keeps none of them afterwards.
481
+ */
482
+ export const make = Effect.fn("Browser.make")(function* (
483
+ context: BrowserContext,
484
+ info: {
485
+ readonly id: string;
486
+ readonly provider: string;
487
+ readonly expiresAt?: DateTime.Utc | undefined;
488
+ /** Where pages' screencasts run, if not on each page's own session. */
489
+ readonly capture?: CaptureSource | undefined;
490
+ },
491
+ options: Options = {},
492
+ ) {
493
+ const clock = yield* Clock.Clock;
494
+ const connection = yield* Identity.make;
495
+ const now = () => Number(clock.monotonicTimeNanosUnsafe()) / 1e6;
496
+ const { settings, eventHistory, maxPages } = yield* settingsOf(options);
497
+ // Measured on first need: the first capture of any page measures it, and input never waits.
498
+ const mapping = BrowserClock.mapping(now);
499
+
500
+ const timeline = Timeline.make(eventHistory);
501
+
502
+ yield* Effect.addFinalizer(() => timeline.close);
503
+ const publish = timeline.publish;
504
+
505
+ if (info.expiresAt !== undefined)
506
+ publish(new SessionEnding({ at: now(), expiresAt: info.expiresAt }));
507
+
508
+ const { lostBy, lost, lose, untilLost, native } = yield* watchLoss(
509
+ context,
510
+ info.expiresAt,
511
+ clock,
512
+ (cause) => publish(new Disconnected({ at: now(), cause })),
513
+ );
514
+
515
+ const inputGate = Gate.make({
516
+ now,
517
+ sequence: timeline.sequence,
518
+ closedBy: lostBy,
519
+ lost,
520
+ clock,
521
+ drain: settings.navigationTimeout,
522
+ });
523
+
524
+ const registry = new Map<PlaywrightPage, Page.Page>();
525
+ const registrations: Openings.Registrations = new WeakMap();
526
+ const registering = yield* Semaphore.make(1);
527
+ // The pages' scopes close after the browser is marked released, so what fails as they close
528
+ // says why.
529
+ const pagesScope = yield* Scope.fork(yield* Scope.Scope);
530
+
531
+ const release = releaseNative(clock);
532
+ const budget = pageBudget(maxPages, () => registry.size, settings.actionTimeout, clock);
533
+
534
+ // Each tab's scope closes with the tab or the browser. A dropped connection closes every page,
535
+ // then the context, and `Disconnected` stands for all of them: the queue hands a close to its
536
+ // fiber in a later turn, once Playwright has reported the context's.
537
+ const closing = yield* closures(lostBy, publish, now);
538
+
539
+ const register = (playwright: PlaywrightPage) =>
540
+ registering.withPermits(1)(
541
+ Effect.gen(function* () {
542
+ const known = registry.get(playwright);
543
+
544
+ if (known !== undefined) return known;
545
+ if (playwright.isClosed())
546
+ return yield* failWith("newPage", new Failed({ detail: "the page closed as it opened" }));
547
+ const scope = yield* Scope.fork(pagesScope);
548
+
549
+ return yield* Effect.gen(function* () {
550
+ let crashed = false;
551
+ const closedBy = () => lostBy() ?? (crashed ? "crashed" : "page");
552
+ const cdp = yield* native("newPage", () => context.newCDPSession(playwright));
553
+
554
+ yield* Effect.addFinalizer(() => release(() => cdp.detach()));
555
+ // The page's name, its target id, costs one round trip, which the calls that keep it
556
+ // painting behind other tabs and report its documents share. A renderer busy with its
557
+ // first script answers those two late, so what needs them waits, and registering does not.
558
+ const send: typeof cdp.send = (method, params) => untilLost(cdp.send(method, params));
559
+ const focusing = sentNow(send("Emulation.setFocusEmulationEnabled", { enabled: true }));
560
+ const paging = sentNow(send("Page.enable"));
561
+ const { targetInfo } = yield* native("newPage", () => send("Target.getTargetInfo"));
562
+ const id = targetInfo.targetId;
563
+
564
+ const page = yield* PageImpl.make({
565
+ id,
566
+ session: info.id,
567
+ connection,
568
+ url: targetInfo.url,
569
+ playwright,
570
+ cdp,
571
+ untilLost,
572
+ settings,
573
+ inputGate,
574
+ clock,
575
+ mapping,
576
+ publish,
577
+ recentEvents: eventsOf(timeline.recent, id),
578
+ openings: yield* Openings.make(playwright, cdp, registrations, closedBy),
579
+ focused: call("focus", () => focusing, closedBy).pipe(Effect.asVoid),
580
+ paging: (operation) => call(operation, () => paging, closedBy).pipe(Effect.asVoid),
581
+ closedBy,
582
+ capture: info.capture,
583
+ });
584
+
585
+ const close = () => {
586
+ if (registry.get(playwright) !== page) return;
587
+ registry.delete(playwright);
588
+ budget.makeRoom();
589
+ Queue.offerUnsafe(closing, { scope, page: id, cause: crashed ? "crashed" : "page" });
590
+ };
591
+
592
+ // Playwright drives a crashed page no more and its calls never return, so it is closed:
593
+ // they fail at once, `Closed` because it crashed.
594
+ const crash = () => {
595
+ crashed = true;
596
+ void playwright.close().catch(() => undefined);
597
+ };
598
+
599
+ registry.set(playwright, page);
600
+ publish(
601
+ new PageOpened({
602
+ at: now(),
603
+ page: id,
604
+ url: Url.redact(targetInfo.url),
605
+ opener: targetInfo.openerId,
606
+ }),
607
+ );
608
+ yield* listen(playwright, id, publish, now, { close, crash });
609
+ if (playwright.isClosed()) close();
610
+
611
+ return page;
612
+ }).pipe(
613
+ Scope.provide(scope),
614
+ Effect.onError(() => Scope.close(scope, Exit.void)),
615
+ );
616
+ }),
617
+ );
618
+
619
+ // Playwright registers its init scripts as it sets up each page, before a popup's first document
620
+ // runs, and they cost no call per document.
621
+ for (const script of options.initScripts ?? [])
622
+ yield* native("initScripts", () => context.addInitScript({ content: scoped(script) }));
623
+ yield* followOpened(context, register, publish, now, registrations);
624
+ for (const existing of context.pages()) yield* register(existing);
625
+
626
+ const pages = Effect.sync(() => [...registry.values()]);
627
+
628
+ const newPage = Effect.fn("Browser.newPage")(function* (url?: string) {
629
+ const page = yield* budget.open(
630
+ native("newPage", () => context.newPage()).pipe(
631
+ Effect.flatMap((playwright) =>
632
+ register(playwright).pipe(Effect.onError(() => release(() => playwright.close()))),
633
+ ),
634
+ ),
635
+ );
636
+
637
+ // A rejected or interrupted navigation must not leave the newly allocated blank tab behind.
638
+ if (url !== undefined)
639
+ yield* page.goto(url).pipe(Effect.onError(() => page.close.pipe(Effect.ignore)));
640
+
641
+ return page;
642
+ });
643
+
644
+ // Added last, so it runs first as the owner's scope closes.
645
+ yield* Effect.addFinalizer(() => Effect.sync(() => lose("released")));
646
+
647
+ const service: Service = {
648
+ id: info.id,
649
+ connection,
650
+ provider: info.provider,
651
+ expiresAt: info.expiresAt,
652
+ now: Effect.sync(now),
653
+ suspend: (options = {}) =>
654
+ Effect.map(inputGate.suspend(options.drain), (held) => new Suspension(held)),
655
+ context,
656
+ pages,
657
+ page: (id) =>
658
+ Effect.map(pages, (open) => Option.fromNullishOr(open.find((page) => page.id === id))),
659
+ firstPage: pages.pipe(
660
+ Effect.map((open) => Option.fromNullishOr(open[0])),
661
+ Effect.flatMap(Option.match({ onNone: () => newPage(), onSome: Effect.succeed })),
662
+ ),
663
+ newPage,
664
+ disconnected: lost,
665
+ events: (eventOptions = {}) => timeline.stream(eventOptions.after),
666
+ recentEvents: timeline.recent.pipe(
667
+ Effect.map((records) => records.map((record) => record.event)),
668
+ ),
669
+ };
670
+
671
+ return Browser.of(service);
672
+ });