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
@@ -0,0 +1,314 @@
1
+ /**
2
+ * What a page's parts share: the browser state they act on, the page's own lane, pointer and
3
+ * clock, and how they call the browser and report its failures. The assembly in `page.ts` builds
4
+ * one context per page.
5
+ */
6
+ import {
7
+ Clock,
8
+ Context,
9
+ Duration,
10
+ Effect,
11
+ Fiber,
12
+ MutableRef,
13
+ Option,
14
+ Schema,
15
+ type Scope,
16
+ } from "effect";
17
+ import type { CDPSession, Page as PlaywrightPage } from "playwright-core";
18
+
19
+ import type { CaptureSource } from "../../Browser.ts";
20
+ import { BrowserError, Closed, Failed, type Reason, Timeout } from "../../BrowserError.ts";
21
+ import type { BrowserEvent } from "../../BrowserEvent.ts";
22
+ import type { InputGuard, Point } from "../../Page.ts";
23
+ import type { Gate } from "../input/gate.ts";
24
+ import { Current } from "../input/replies.ts";
25
+ import type * as BrowserClock from "../pictures/clock.ts";
26
+ import * as Lane from "./lane.ts";
27
+ import type { Openings } from "./openings.ts";
28
+ import * as Url from "./url.ts";
29
+
30
+ /** Why a page is gone. */
31
+ export type ClosedCause = Closed["cause"];
32
+
33
+ /** What every page of a browser shares, from its options. */
34
+ export interface Settings {
35
+ readonly actionTimeout: Duration.Duration;
36
+ readonly policyTimeout: Duration.Duration;
37
+ readonly navigationTimeout: Duration.Duration;
38
+ /** How long screencast frames stay in `recentFrames`, measured back from the newest. */
39
+ readonly frameHistory: Duration.Duration;
40
+ readonly guard: InputGuard | undefined;
41
+ }
42
+
43
+ export interface MakeOptions {
44
+ /** The page's CDP target id, which is also its main frame's id. */
45
+ readonly id: string;
46
+ /** The id of the browser's session, which its frames carry. */
47
+ readonly session: string;
48
+ /** This tracking connection, distinct from the provider session and capture socket. */
49
+ readonly connection: string;
50
+ /** The page's address when the browser began tracking it. */
51
+ readonly url: string;
52
+ readonly playwright: PlaywrightPage;
53
+ readonly cdp: CDPSession;
54
+ /** A call's reply, or a failure as for a closed page once the browser is lost first. */
55
+ readonly untilLost: <A>(reply: Promise<A>) => Promise<A>;
56
+ readonly settings: Settings;
57
+ readonly clock: Clock.Clock;
58
+ /** Browser-wide input authority; direct internal page fixtures need not provide one. */
59
+ readonly inputGate?: Gate | undefined;
60
+ /** The browser's epoch mapping: its first capture measures it, and later ones renew it. */
61
+ readonly mapping: BrowserClock.Mapping;
62
+ readonly publish: (event: BrowserEvent) => number;
63
+ /** This page's retained events, oldest first. */
64
+ readonly recentEvents: Effect.Effect<ReadonlyArray<BrowserEvent>>;
65
+ /** Registration of the actual popups this page's current input opened. */
66
+ readonly openings: Openings;
67
+ /** Succeeds once the page's own session holds focus emulation, which keeps it painting behind. */
68
+ readonly focused: Effect.Effect<void, BrowserError>;
69
+ /** Succeeds once the page's own session has the Page domain on, so it sees every later commit. */
70
+ readonly paging: (operation: string) => Effect.Effect<void, BrowserError>;
71
+ /** Why the page is gone, once it is: its browser's loss, else its own close or crash. */
72
+ readonly closedBy: () => ClosedCause;
73
+ /** Where the page's screencast runs, if not on its own session. */
74
+ readonly capture?: CaptureSource | undefined;
75
+ }
76
+
77
+ const messageOf = (cause: unknown): string =>
78
+ cause instanceof Error ? cause.message : typeof cause === "string" ? cause : "unknown error";
79
+
80
+ const closedPattern =
81
+ /has been closed|Target closed|Session closed|browser has disconnected|Target page, context or browser/i;
82
+
83
+ /**
84
+ * Map a Playwright or protocol failure to a reason, `Closed` with `closed` as its cause for a page
85
+ * or browser that is gone. A call that gives Playwright a timeout passes the same bound, so its
86
+ * `Timeout` reports it; without one, Playwright's own message stays.
87
+ */
88
+ export const reasonOf = (
89
+ cause: unknown,
90
+ timeoutMillis?: number,
91
+ closed: ClosedCause = "connection",
92
+ ): Reason => {
93
+ const message = messageOf(cause);
94
+
95
+ if (closedPattern.test(message)) return new Closed({ cause: closed });
96
+ if (cause instanceof Error && cause.name === "TimeoutError" && timeoutMillis !== undefined)
97
+ return new Timeout({ millis: timeoutMillis });
98
+ const line = message.split("\n")[0] ?? message;
99
+
100
+ return new Failed({ detail: Url.redactWithin(line.replace(/^[\w.]+: /, "")) });
101
+ };
102
+
103
+ /**
104
+ * One Playwright or protocol call, failing undispatched. A closed page says why it is gone as the
105
+ * browser knows then: the library's own calls fail with the browser's loss, and Playwright fails
106
+ * its calls on a dropped connection after reporting the context closed.
107
+ */
108
+ export const call = <A>(
109
+ operation: string,
110
+ run: () => Promise<A>,
111
+ closedBy: () => ClosedCause,
112
+ timeoutMillis?: number,
113
+ ) =>
114
+ Effect.tryPromise({
115
+ try: run,
116
+ catch: (cause) => undispatched(operation, reasonOf(cause, timeoutMillis, closedBy())),
117
+ });
118
+
119
+ export const contextGone = (error: BrowserError) =>
120
+ error.reason._tag === "Failed" &&
121
+ /Cannot find context|Execution context was destroyed|__effectBrowser|Inspected target navigated/i.test(
122
+ error.reason.detail,
123
+ );
124
+
125
+ /**
126
+ * What a page operation spent on the page's own protocol session: its calls, the bytes of their
127
+ * parameters and results, and how long at least one of them awaited its reply. An operation inside
128
+ * another counts toward both.
129
+ */
130
+ interface Cost {
131
+ readonly enclosing: Cost | undefined;
132
+ calls: number;
133
+ bytesOut: number;
134
+ bytesIn: number;
135
+ waitedMillis: number;
136
+ awaiting: number;
137
+ awaitingSince: number;
138
+ }
139
+
140
+ /** The cost of the page operation the current fiber runs, if any. */
141
+ const CurrentCost = Context.Reference<Cost | undefined>(
142
+ "effect-browser/internal/page/CurrentCost",
143
+ {
144
+ defaultValue: () => undefined,
145
+ },
146
+ );
147
+
148
+ /** Track the promise returned to the action, after tracing wraps it, so input counts once. */
149
+ const track = <A>(reply: Promise<A>) => Fiber.getCurrent()?.getRef(Current)?.track(reply) ?? reply;
150
+
151
+ // A message's size on the wire, give or take its envelope.
152
+ const sizeOf = (value: unknown) =>
153
+ value === undefined ? 0 : Buffer.byteLength(JSON.stringify(value));
154
+
155
+ /** A failure of `operation` before any of its input reached the browser. */
156
+ export const undispatched = (operation: string, reason: Reason) =>
157
+ new BrowserError({ operation, reason, dispatched: false });
158
+
159
+ export const failWith = (operation: string, reason: Reason) =>
160
+ Effect.fail(undispatched(operation, reason));
161
+
162
+ export const decodeWith =
163
+ <A>(operation: string, schema: Schema.Codec<A, unknown>) =>
164
+ (value: unknown) =>
165
+ Schema.decodeUnknownEffect(schema)(value).pipe(
166
+ Effect.mapError((error) => undispatched(operation, new Failed({ detail: error.message }))),
167
+ );
168
+
169
+ export const make = (options: MakeOptions, scope: Scope.Scope) => {
170
+ const { id, clock } = options;
171
+ const now = () => Number(clock.monotonicTimeNanosUnsafe()) / 1e6;
172
+ // Deadlines and pacing guard the page's lane, so page operations run on the owner's clock; a
173
+ // caller's clock, such as a TestClock, cannot stall or stretch them.
174
+ const owned = Effect.provideService(Clock.Clock, clock);
175
+
176
+ // This page's spans name it and report what the operation cost on the protocol. Fine-grained
177
+ // ones carry a level below the default Info, so an application keeps only the coarse ones by
178
+ // raising `Tracer.MinimumTraceLevel`.
179
+ const span =
180
+ (name: string, attributes: Record<string, unknown> = {}, level?: "Debug" | "Trace") =>
181
+ <A, E, R>(effect: Effect.Effect<A, E, R>) =>
182
+ Effect.gen(function* () {
183
+ const cost: Cost = {
184
+ enclosing: yield* CurrentCost,
185
+ calls: 0,
186
+ bytesOut: 0,
187
+ bytesIn: 0,
188
+ waitedMillis: 0,
189
+ awaiting: 0,
190
+ awaitingSince: 0,
191
+ };
192
+
193
+ return yield* effect.pipe(
194
+ Effect.provideService(CurrentCost, cost),
195
+ Effect.ensuring(
196
+ Effect.suspend(() =>
197
+ Effect.annotateCurrentSpan({
198
+ calls: cost.calls,
199
+ bytesOut: cost.bytesOut,
200
+ bytesIn: cost.bytesIn,
201
+ waitedMillis: Math.round(cost.waitedMillis * 10) / 10,
202
+ }),
203
+ ),
204
+ ),
205
+ );
206
+ }).pipe(
207
+ Effect.withSpan(
208
+ name,
209
+ { attributes: { page: id, ...attributes }, ...(level === undefined ? {} : { level }) },
210
+ { captureStackTrace: false },
211
+ ),
212
+ );
213
+
214
+ // Every call on the page's own protocol session, counted into the operation that sends it and
215
+ // the operations around that one. It is looked up when the call is sent, so input pipelined
216
+ // from inside an operation counts toward it too.
217
+ const send: CDPSession["send"] = (method, params) => {
218
+ const cost = Fiber.getCurrent()?.getRef(CurrentCost);
219
+ const sentAt = now();
220
+ const bytesOut = sizeOf(params);
221
+
222
+ for (let each = cost; each !== undefined; each = each.enclosing) {
223
+ each.calls++;
224
+ each.bytesOut += bytesOut;
225
+ if (each.awaiting++ === 0) each.awaitingSince = sentAt;
226
+ }
227
+ const reply = options.untilLost(options.cdp.send(method, params));
228
+
229
+ if (cost === undefined) return track(reply);
230
+
231
+ const settle = (bytesIn: number) => {
232
+ const at = now();
233
+
234
+ for (let each: Cost | undefined = cost; each !== undefined; each = each.enclosing) {
235
+ each.bytesIn += bytesIn;
236
+ if (--each.awaiting === 0) each.waitedMillis += at - each.awaitingSince;
237
+ }
238
+ };
239
+
240
+ return track(
241
+ reply.then(
242
+ (result) => {
243
+ settle(sizeOf(result));
244
+
245
+ return result;
246
+ },
247
+ (cause: unknown) => {
248
+ settle(0);
249
+
250
+ return Promise.reject(cause);
251
+ },
252
+ ),
253
+ );
254
+ };
255
+
256
+ // Every protocol or Playwright call. Errors are undispatched here; `perform` marks them
257
+ // dispatched once input has gone out.
258
+ const native = <A>(operation: string, run: () => Promise<A>, timeoutMillis?: number) =>
259
+ call(operation, () => track(run()), options.closedBy, timeoutMillis);
260
+
261
+ /** Fail with `Timeout` once `duration` has passed, the action timeout unless another is given. */
262
+ const within =
263
+ (operation: string, duration: Duration.Input = options.settings.actionTimeout) =>
264
+ <A, E, R>(effect: Effect.Effect<A, E, R>) =>
265
+ Effect.timeoutOrElse(effect, {
266
+ duration,
267
+ orElse: () => failWith(operation, new Timeout({ millis: Duration.toMillis(duration) })),
268
+ });
269
+
270
+ // The latest submitted input or page change. A read that must follow it waits for the action in
271
+ // flight, so only paint from after its latest input is current. An action ends after its input
272
+ // was handled, so if that input changed the page, newer paint follows; a lost final paint is
273
+ // bounded by recency instead. Paint from before the current document began, when the page's own
274
+ // session saw the main frame commit it, shows a page the tab has left, so no read reuses it,
275
+ // whatever it asks.
276
+ const activity = { inputAt: 0, documentAt: Number.NEGATIVE_INFINITY };
277
+
278
+ const noteInput = () => {
279
+ activity.inputAt = Math.max(activity.inputAt, now());
280
+ };
281
+
282
+ const lane = Lane.make({
283
+ now,
284
+ actionTimeout: options.settings.actionTimeout,
285
+ documentAt: () => activity.documentAt,
286
+ gone: (operation) => undispatched(operation, new Closed({ cause: options.closedBy() })),
287
+ scope,
288
+ inputGate: options.inputGate,
289
+ });
290
+
291
+ // Where this page's latest move left its pointer, which is where its next plain glide starts and
292
+ // where a held button is released. Its lane orders the page's input, and Chromium keeps a
293
+ // pointer per page too.
294
+ const pointer = MutableRef.make(Option.none<Point>());
295
+
296
+ // Named, so that the declarations of what holds it need not spell out Playwright's protocol types.
297
+ const protocol: Pick<CDPSession, "send"> = { send };
298
+
299
+ return {
300
+ ...options,
301
+ lane,
302
+ pointer,
303
+ now,
304
+ owned,
305
+ span,
306
+ protocol,
307
+ native,
308
+ within,
309
+ activity,
310
+ noteInput,
311
+ };
312
+ };
313
+
314
+ export type PageContext = ReturnType<typeof make>;
@@ -0,0 +1,35 @@
1
+ /** Connection identity uses the supplied Crypto service, or the host's Web Crypto on Node/Bun. */
2
+ import { Crypto, Effect, Option, PlatformError } from "effect";
3
+
4
+ import { Failed } from "../../BrowserError.ts";
5
+ import { undispatched } from "./context.ts";
6
+
7
+ // The core owns this tiny platform adapter to keep Chromium/CDP usable without another package
8
+ // or a new required service. Browser construction captures it once; there is no global counter.
9
+ const native = () =>
10
+ Crypto.make({
11
+ randomBytes: (size) => globalThis.crypto.getRandomValues(new Uint8Array(size)),
12
+ digest: (algorithm, data) =>
13
+ Effect.tryPromise({
14
+ try: () => globalThis.crypto.subtle.digest(algorithm, new Uint8Array(data)),
15
+ catch: (cause) =>
16
+ PlatformError.systemError({
17
+ module: "Crypto",
18
+ method: "digest",
19
+ _tag: "Unknown",
20
+ description: "Could not compute digest",
21
+ cause,
22
+ }),
23
+ }).pipe(Effect.map((bytes) => new Uint8Array(bytes))),
24
+ });
25
+
26
+ export const make = Effect.gen(function* () {
27
+ const supplied = yield* Effect.serviceOption(Crypto.Crypto);
28
+ const crypto = Option.getOrElse(supplied, native);
29
+
30
+ return yield* crypto.randomUUIDv4.pipe(
31
+ Effect.mapError(() =>
32
+ undispatched("make", new Failed({ detail: "Could not name connection" })),
33
+ ),
34
+ );
35
+ });
@@ -0,0 +1,311 @@
1
+ /**
2
+ * A page's admission: the one lane every operation on the page takes its turn in, so a page waits
3
+ * only for itself. A write, an action that sends input or navigates, holds the lane alone, in the
4
+ * order writes asked; reads share it, after the write in flight and every write asked before them,
5
+ * so a read describes the page an action left, never one an action is still changing. Each write
6
+ * starts a new epoch as it asks. A turn not given in time fails `Busy`, never `Timeout`, and a
7
+ * caller under `FailFast` gets it at once.
8
+ *
9
+ * Reads keep their work. An identical read asked in the same epoch and document joins the one in
10
+ * flight, which runs in the page's scope under the action timeout whoever gives up; one that ends
11
+ * after all its callers gave up serves the next to ask the same, within an action timeout. A write
12
+ * stops the reads nobody awaits, since no caller after it could use them.
13
+ *
14
+ * Effect's `Semaphore` is not fair to a writer that needs every permit, so the rule is this
15
+ * module's own: one state, changed by `step` alone, which the model test drives.
16
+ */
17
+ import { Context, Deferred, Duration, Effect, Exit, type Fiber, type Scope } from "effect";
18
+
19
+ import { BrowserError, Busy } from "../../BrowserError.ts";
20
+ import type { Gate } from "../input/gate.ts";
21
+
22
+ export type Kind = "read" | "write";
23
+
24
+ export interface Ticket {
25
+ readonly id: number;
26
+ readonly kind: Kind;
27
+ }
28
+
29
+ /** Who holds the lane: no one, reads together, or one write. */
30
+ export type Turn =
31
+ | { readonly _tag: "Free" }
32
+ | { readonly _tag: "Reading"; readonly count: number }
33
+ | { readonly _tag: "Writing" };
34
+
35
+ export interface State {
36
+ readonly turn: Turn;
37
+ /** The tickets waiting, in the order they asked. */
38
+ readonly queue: ReadonlyArray<Ticket>;
39
+ /** The writes asked so far: two reads in one epoch follow the same writes. */
40
+ readonly epoch: number;
41
+ }
42
+
43
+ export type Input =
44
+ | { readonly _tag: "Ask"; readonly ticket: Ticket }
45
+ /** The ticket gave up waiting, or ended its turn. */
46
+ | { readonly _tag: "Leave"; readonly ticket: Ticket };
47
+
48
+ export const initial: State = { turn: { _tag: "Free" }, queue: [], epoch: 0 };
49
+
50
+ const holders = (turn: Turn) =>
51
+ turn._tag === "Reading" ? turn.count : turn._tag === "Writing" ? 1 : 0;
52
+
53
+ /** Whether a ticket of `kind` asking now would hold the lane at once. */
54
+ export const admitsNow = ({ turn, queue }: State, kind: Kind) =>
55
+ queue.length === 0 && (kind === "read" ? turn._tag !== "Writing" : turn._tag === "Free");
56
+
57
+ /**
58
+ * The operations ahead of a waiting ticket: those holding the lane and those asked before it, or
59
+ * all that are queued for a ticket not in the queue.
60
+ */
61
+ export const ahead = ({ turn, queue }: State, id?: number) => {
62
+ const at = queue.findIndex((ticket) => ticket.id === id);
63
+
64
+ return holders(turn) + (at === -1 ? queue.length : at);
65
+ };
66
+
67
+ /**
68
+ * The lane after `input`, and the tickets it lets in. Tickets go in from the head of the queue
69
+ * only: a write once the lane is free, reads while no write holds it, so reads asked before a
70
+ * write go before it, and reads asked after it wait for it.
71
+ */
72
+ export const step = (
73
+ state: State,
74
+ input: Input,
75
+ ): { readonly state: State; readonly admitted: ReadonlyArray<Ticket> } => {
76
+ let { turn, queue, epoch } = state;
77
+
78
+ if (input._tag === "Ask") {
79
+ queue = [...queue, input.ticket];
80
+ if (input.ticket.kind === "write") epoch += 1;
81
+ } else {
82
+ const at = queue.findIndex((ticket) => ticket.id === input.ticket.id);
83
+
84
+ if (at !== -1) queue = queue.toSpliced(at, 1);
85
+ else
86
+ turn =
87
+ turn._tag === "Reading" && turn.count > 1
88
+ ? { _tag: "Reading", count: turn.count - 1 }
89
+ : { _tag: "Free" };
90
+ }
91
+
92
+ const admitted: Array<Ticket> = [];
93
+
94
+ for (const ticket of queue) {
95
+ if (ticket.kind === "write" ? turn._tag !== "Free" : turn._tag === "Writing") break;
96
+ admitted.push(ticket);
97
+ turn =
98
+ ticket.kind === "write" ? { _tag: "Writing" } : { _tag: "Reading", count: holders(turn) + 1 };
99
+ }
100
+
101
+ return { state: { turn, queue: queue.slice(admitted.length), epoch }, admitted };
102
+ };
103
+
104
+ /** Whether operations on a busy page fail `Busy` at once rather than wait their turn. */
105
+ export const FailFast = Context.Reference<boolean>("effect-browser/internal/page/FailFast", {
106
+ defaultValue: () => false,
107
+ });
108
+
109
+ /** The id a caller gives the actions it asks for, which each records as its `correlation`. */
110
+ export const Correlation = Context.Reference<string | undefined>(
111
+ "effect-browser/internal/page/Correlation",
112
+ { defaultValue: () => undefined },
113
+ );
114
+
115
+ /** A shared read in flight: its epoch, its result once it ends, and how many callers await it. */
116
+ interface Flight<A> {
117
+ readonly epoch: number;
118
+ readonly result: Deferred.Deferred<A, BrowserError>;
119
+ waiting: number;
120
+ fiber: Fiber.Fiber<A, BrowserError> | undefined;
121
+ }
122
+
123
+ export const make = (options: {
124
+ readonly now: () => number;
125
+ readonly actionTimeout: Duration.Duration;
126
+ /** When the page's current document began: a read is shared only within one. */
127
+ readonly documentAt: () => number;
128
+ /** The failure of a read still awaited as the page closes. */
129
+ readonly gone: (operation: string) => BrowserError;
130
+ /** The page's scope, which shared reads run in. */
131
+ readonly scope: Scope.Scope;
132
+ readonly inputGate?: Gate | undefined;
133
+ }) => {
134
+ const { now, actionTimeout } = options;
135
+ let state = initial;
136
+ let tickets = 0;
137
+ const admissions = new Map<number, Deferred.Deferred<void>>();
138
+ // Each shared read's sweep, which a write runs as it asks.
139
+ const sweeps: Array<() => void> = [];
140
+
141
+ const apply = (input: Input) => {
142
+ const next = step(state, input);
143
+
144
+ state = next.state;
145
+ if (input._tag === "Leave") admissions.delete(input.ticket.id);
146
+ for (const ticket of next.admitted) {
147
+ const admitted = admissions.get(ticket.id);
148
+
149
+ admissions.delete(ticket.id);
150
+ if (admitted !== undefined) Deferred.doneUnsafe(admitted, Exit.void);
151
+ }
152
+ if (input._tag === "Ask" && input.ticket.kind === "write") for (const sweep of sweeps) sweep();
153
+ };
154
+
155
+ const busy = (operation: string, askedAt: number, id?: number) =>
156
+ new BrowserError({
157
+ operation,
158
+ reason: new Busy({ waitedMillis: Math.round(now() - askedAt), ahead: ahead(state, id) }),
159
+ dispatched: false,
160
+ });
161
+
162
+ /**
163
+ * Run `effect` in a turn of `kind`, waiting at most `wait` for it, or failing `Busy`. The turn
164
+ * ends with `effect`, however it ends.
165
+ */
166
+ const turn =
167
+ (kind: Kind, operation: string, wait: Duration.Duration) =>
168
+ <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | BrowserError, R> =>
169
+ Effect.uninterruptibleMask((restore) =>
170
+ Effect.gen(function* () {
171
+ const ticket: Ticket = { id: ++tickets, kind };
172
+ const askedAt = now();
173
+
174
+ if (kind === "write" && options.inputGate !== undefined)
175
+ yield* options.inputGate.check(operation);
176
+
177
+ if ((yield* FailFast) && !admitsNow(state, kind)) return yield* busy(operation, askedAt);
178
+ const admitted = Deferred.makeUnsafe<void>();
179
+
180
+ admissions.set(ticket.id, admitted);
181
+ apply({ _tag: "Ask", ticket });
182
+
183
+ const waiting = Deferred.await(admitted).pipe(
184
+ Effect.timeoutOrElse({
185
+ duration: wait,
186
+ orElse: () =>
187
+ Deferred.isDoneUnsafe(admitted)
188
+ ? Effect.void
189
+ : Effect.fail(busy(operation, askedAt, ticket.id)),
190
+ }),
191
+ );
192
+
193
+ const given = yield* Effect.exit(
194
+ restore(
195
+ kind === "write" && options.inputGate !== undefined
196
+ ? Effect.raceFirst(waiting, options.inputGate.refused(operation))
197
+ : waiting,
198
+ ),
199
+ );
200
+
201
+ if (Exit.isFailure(given)) {
202
+ apply({ _tag: "Leave", ticket });
203
+
204
+ return yield* Effect.failCause(given.cause);
205
+ }
206
+
207
+ return yield* restore(effect).pipe(
208
+ Effect.ensuring(Effect.sync(() => apply({ _tag: "Leave", ticket }))),
209
+ );
210
+ }),
211
+ );
212
+
213
+ /**
214
+ * A read whose identical calls share their work: `work` runs once per epoch and document while
215
+ * calls await it, and with `keep` its result serves the next call after all of them gave up,
216
+ * within an action timeout of its end. Pictures, whose callers say how old they may be, only
217
+ * join.
218
+ */
219
+ const shared = <A>(operation: string, keep: boolean) => {
220
+ const flights = new Map<string, Flight<A>>();
221
+ const kept = new Map<string, { readonly value: A; readonly until: number }>();
222
+
223
+ sweeps.push(() => {
224
+ kept.clear();
225
+ for (const flight of flights.values())
226
+ if (flight.waiting === 0) flight.fiber?.interruptUnsafe();
227
+ });
228
+
229
+ // A read in flight, its first caller waiting. However its fiber ends, even before it began,
230
+ // as when the page closes, its callers hear.
231
+ const start = (id: string, work: Effect.Effect<A, BrowserError>) =>
232
+ Effect.gen(function* () {
233
+ const flight: Flight<A> = {
234
+ epoch: state.epoch,
235
+ result: Deferred.makeUnsafe(),
236
+ waiting: 1,
237
+ fiber: undefined,
238
+ };
239
+
240
+ flights.set(id, flight);
241
+ flight.fiber = yield* turn(
242
+ "read",
243
+ operation,
244
+ actionTimeout,
245
+ )(work).pipe(
246
+ // The caller chose whether to wait; the work it started serves others too.
247
+ Effect.provideService(FailFast, false),
248
+ Effect.forkIn(options.scope),
249
+ );
250
+ flight.fiber.addObserver((exit) => {
251
+ flights.delete(id);
252
+ if (keep && Exit.isSuccess(exit) && flight.waiting === 0 && flight.epoch === state.epoch)
253
+ kept.set(id, { value: exit.value, until: now() + Duration.toMillis(actionTimeout) });
254
+ Deferred.doneUnsafe(
255
+ flight.result,
256
+ Exit.hasInterrupts(exit) ? Effect.fail(options.gone(operation)) : exit,
257
+ );
258
+ });
259
+
260
+ return flight;
261
+ });
262
+
263
+ const join = (id: string, work: Effect.Effect<A, BrowserError>) =>
264
+ Effect.acquireUseRelease(
265
+ Effect.suspend(() => {
266
+ const flying = flights.get(id);
267
+
268
+ if (flying === undefined) return start(id, work);
269
+ flying.waiting += 1;
270
+
271
+ return Effect.as(Effect.annotateCurrentSpan("shared", "joined"), flying);
272
+ }),
273
+ (flight) => Deferred.await(flight.result),
274
+ (flight) =>
275
+ Effect.sync(() => {
276
+ flight.waiting -= 1;
277
+ if (flight.waiting === 0 && flight.epoch < state.epoch) flight.fiber?.interruptUnsafe();
278
+ }),
279
+ );
280
+
281
+ return (key: string, work: Effect.Effect<A, BrowserError>): Effect.Effect<A, BrowserError> =>
282
+ Effect.gen(function* () {
283
+ const id = `${state.epoch} ${options.documentAt()} ${key}`;
284
+ const at = now();
285
+
286
+ for (const [old, { until }] of kept) if (until < at) kept.delete(old);
287
+ const hit = kept.get(id);
288
+
289
+ if (hit !== undefined) {
290
+ kept.delete(id);
291
+ yield* Effect.annotateCurrentSpan("shared", "kept");
292
+
293
+ return hit.value;
294
+ }
295
+ if ((yield* FailFast) && !admitsNow(state, "read")) return yield* busy(operation, at);
296
+
297
+ return yield* join(id, work);
298
+ });
299
+ };
300
+
301
+ return {
302
+ /** A turn alone, for an action: `wait` bounds the wait for it. */
303
+ write: (operation: string, wait: Duration.Duration) => turn("write", operation, wait),
304
+ /** A turn beside other reads, waited for within the action timeout. */
305
+ read: (operation: string, wait: Duration.Duration = actionTimeout) =>
306
+ turn("read", operation, wait),
307
+ shared,
308
+ };
309
+ };
310
+
311
+ export type Lane = ReturnType<typeof make>;