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

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 (202) hide show
  1. package/README.md +707 -1184
  2. package/dist/Browser-B5AV7zPS.mjs +437 -0
  3. package/dist/Browser-B5AV7zPS.mjs.map +1 -0
  4. package/dist/Browser.d.mts +115 -3
  5. package/dist/Browser.mjs +2 -22
  6. package/dist/BrowserError-DXXIA6Rk.d.mts +135 -0
  7. package/dist/BrowserError.d.mts +2 -0
  8. package/dist/BrowserError.mjs +202 -0
  9. package/dist/BrowserError.mjs.map +1 -0
  10. package/dist/BrowserEvent-DzdNdvoh.d.mts +277 -0
  11. package/dist/BrowserEvent.d.mts +2 -0
  12. package/dist/BrowserEvent.mjs +295 -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-DVP2IJWm.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-BERmf_gM.d.mts +117 -0
  29. package/dist/Frame.d.mts +2 -0
  30. package/dist/Frame.mjs +123 -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-D4281dIi.mjs +618 -0
  39. package/dist/Page-D4281dIi.mjs.map +1 -0
  40. package/dist/Page-DYTdVhYa.d.mts +593 -0
  41. package/dist/Page.d.mts +2 -0
  42. package/dist/Page.mjs +2 -0
  43. package/dist/Plan-5MdsRzE5.mjs +314 -0
  44. package/dist/Plan-5MdsRzE5.mjs.map +1 -0
  45. package/dist/Plan-CNNOU1Tx.d.mts +112 -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 +54 -0
  59. package/dist/Stage.mjs +133 -0
  60. package/dist/Stage.mjs.map +1 -0
  61. package/dist/Supervisor-BtEG93gR.mjs +420 -0
  62. package/dist/Supervisor-BtEG93gR.mjs.map +1 -0
  63. package/dist/Supervisor-Im6-wbg5.d.mts +230 -0
  64. package/dist/Supervisor.d.mts +2 -0
  65. package/dist/Supervisor.mjs +2 -0
  66. package/dist/context-DAYjATgC.mjs +137 -0
  67. package/dist/context-DAYjATgC.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-BkRcLCWZ.mjs +5097 -0
  73. package/dist/page-BkRcLCWZ.mjs.map +1 -0
  74. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  75. package/dist/url-CcL0K7S0.mjs +262 -0
  76. package/dist/url-CcL0K7S0.mjs.map +1 -0
  77. package/package.json +96 -61
  78. package/src/Browser.ts +625 -0
  79. package/src/BrowserError.ts +225 -0
  80. package/src/BrowserEvent.ts +294 -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 +130 -0
  86. package/src/Moment.ts +479 -0
  87. package/src/Motion.ts +166 -0
  88. package/src/Page.ts +520 -0
  89. package/src/Plan.ts +354 -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 +223 -0
  94. package/src/Supervisor.ts +353 -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/guard.inpage.ts +605 -0
  101. package/src/internal/input/guard.ts +183 -0
  102. package/src/internal/input/keyboard.ts +112 -0
  103. package/src/internal/input/keys.ts +127 -0
  104. package/src/internal/input/perform.ts +322 -0
  105. package/src/internal/input/pointer.ts +153 -0
  106. package/src/internal/input/replies.ts +159 -0
  107. package/src/internal/input/settle.ts +50 -0
  108. package/src/internal/input/style.ts +186 -0
  109. package/src/internal/input/targets.inpage.ts +293 -0
  110. package/src/internal/input/targets.ts +148 -0
  111. package/src/internal/page/bridge.ts +386 -0
  112. package/src/internal/page/context.ts +302 -0
  113. package/src/internal/page/lane.ts +301 -0
  114. package/src/internal/page/navigation.ts +183 -0
  115. package/src/internal/page/openings.ts +87 -0
  116. package/src/internal/page/page.ts +137 -0
  117. package/src/internal/page/url.ts +100 -0
  118. package/src/internal/page/viewport.ts +43 -0
  119. package/src/internal/pictures/capture.ts +778 -0
  120. package/src/internal/pictures/clock.ts +141 -0
  121. package/src/internal/pictures/pictures.ts +520 -0
  122. package/src/internal/pictures/transport.ts +242 -0
  123. package/src/internal/reading/choose.ts +100 -0
  124. package/src/internal/reading/context.inpage.ts +201 -0
  125. package/src/internal/reading/controls.inpage.ts +158 -0
  126. package/src/internal/reading/expect.inpage.ts +70 -0
  127. package/src/internal/reading/match.inpage.ts +92 -0
  128. package/src/internal/reading/names.inpage.ts +306 -0
  129. package/src/internal/reading/outline.inpage.ts +260 -0
  130. package/src/internal/reading/reading.ts +223 -0
  131. package/src/internal/reading/ready.inpage.ts +154 -0
  132. package/src/internal/reading/ready.ts +105 -0
  133. package/src/internal/reading/subjects.inpage.ts +188 -0
  134. package/src/internal/reading/text.inpage.ts +135 -0
  135. package/src/internal/reading/walk.inpage.ts +220 -0
  136. package/src/internal/supervisor/lifecycle.ts +265 -0
  137. package/src/internal/timeline/changes.inpage.ts +216 -0
  138. package/src/internal/timeline/changes.ts +100 -0
  139. package/src/internal/timeline/events.ts +93 -0
  140. package/src/internal/timeline/fold.inpage.ts +123 -0
  141. package/src/internal/timeline/history.inpage.ts +229 -0
  142. package/src/internal/timeline/marks.inpage.ts +207 -0
  143. package/src/internal/timeline/record.inpage.ts +133 -0
  144. package/src/internal/timeline/sight.inpage.ts +122 -0
  145. package/src/internal/timeline/window.ts +96 -0
  146. package/src/internal/timeline/words.ts +65 -0
  147. package/dist/Bootstrap-B9UUXsvF.d.mts +0 -183
  148. package/dist/Bootstrap-CeQOWBy6.mjs +0 -189
  149. package/dist/Bootstrap-CeQOWBy6.mjs.map +0 -1
  150. package/dist/Bootstrap.d.mts +0 -3
  151. package/dist/Bootstrap.mjs +0 -2
  152. package/dist/Browser-BF9_sORP.d.mts +0 -388
  153. package/dist/Browser.mjs.map +0 -1
  154. package/dist/BrowserData-CPIIIHol.d.mts +0 -585
  155. package/dist/BrowserData.d.mts +0 -3
  156. package/dist/BrowserData.mjs +0 -695
  157. package/dist/BrowserData.mjs.map +0 -1
  158. package/dist/BrowserRuntime-B6nT1G4X.d.mts +0 -148
  159. package/dist/BrowserRuntime-C1r2aSRK.mjs +0 -6820
  160. package/dist/BrowserRuntime-C1r2aSRK.mjs.map +0 -1
  161. package/dist/BrowserRuntime.d.mts +0 -3
  162. package/dist/BrowserRuntime.mjs +0 -3
  163. package/dist/Capture.d.mts +0 -56
  164. package/dist/Capture.mjs +0 -678
  165. package/dist/Capture.mjs.map +0 -1
  166. package/dist/CaptureData-CYriZHX1.mjs +0 -201
  167. package/dist/CaptureData-CYriZHX1.mjs.map +0 -1
  168. package/dist/CaptureData.d.mts +0 -219
  169. package/dist/CaptureData.mjs +0 -2
  170. package/dist/Errors.d.mts +0 -158
  171. package/dist/Errors.mjs +0 -207
  172. package/dist/Errors.mjs.map +0 -1
  173. package/dist/Images-yCG9BmRU.mjs +0 -76
  174. package/dist/Images-yCG9BmRU.mjs.map +0 -1
  175. package/dist/OperationOptions-DAeRS6O9.mjs +0 -55
  176. package/dist/OperationOptions-DAeRS6O9.mjs.map +0 -1
  177. package/dist/PageControl.d.mts +0 -15
  178. package/dist/PageControl.mjs +0 -34
  179. package/dist/PageControl.mjs.map +0 -1
  180. package/dist/PageRegistry-B3q9DS3g.mjs +0 -164
  181. package/dist/PageRegistry-B3q9DS3g.mjs.map +0 -1
  182. package/dist/Plan-Dyxu2q8C.mjs +0 -543
  183. package/dist/Plan-Dyxu2q8C.mjs.map +0 -1
  184. package/dist/PlanData-B668RxbQ.d.mts +0 -4831
  185. package/dist/PlanData.d.mts +0 -3
  186. package/dist/PlanData.mjs +0 -268
  187. package/dist/PlanData.mjs.map +0 -1
  188. package/dist/PlanInput-BJcPPncs.mjs +0 -75
  189. package/dist/PlanInput-BJcPPncs.mjs.map +0 -1
  190. package/dist/PublicSession-DCSMMqOj.mjs +0 -5602
  191. package/dist/PublicSession-DCSMMqOj.mjs.map +0 -1
  192. package/dist/Testing.d.mts +0 -479
  193. package/dist/Testing.mjs +0 -2147
  194. package/dist/Testing.mjs.map +0 -1
  195. package/dist/Timeline-fV22Xk-S.d.mts +0 -29
  196. package/dist/Timeline.d.mts +0 -4
  197. package/dist/Timeline.mjs +0 -20
  198. package/dist/Timeline.mjs.map +0 -1
  199. package/dist/TimelineData-BsOn-YGZ.d.mts +0 -3818
  200. package/dist/TimelineData.d.mts +0 -3
  201. package/dist/TimelineData.mjs +0 -482
  202. package/dist/TimelineData.mjs.map +0 -1
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Performing one action: its turn on the page, the input guard's step, the input run that owns its
3
+ * replies, and the action's record. Its deadline leaves out the time its style takes to show it.
4
+ */
5
+ import { Duration, Effect, Exit, Option, Ref } from "effect";
6
+
7
+ import { BrowserError, PolicyTimeout, Timeout } from "../../BrowserError.ts";
8
+ import { Action, type ActionOptions, Subject } from "../../BrowserEvent.ts";
9
+ import { type Point, redacted, type ResolvedTarget } from "../../Page.ts";
10
+ import { failWith, type PageContext, undispatched } from "../page/context.ts";
11
+ import { Correlation } from "../page/lane.ts";
12
+ import type * as BrowserClock from "../pictures/clock.ts";
13
+ import type { Dispatch } from "./dispatch.ts";
14
+ import type { Approval, PolicyPlan } from "./guard.ts";
15
+ import * as Replies from "./replies.ts";
16
+ import { plain, type Style } from "./style.ts";
17
+
18
+ export interface InputMarks {
19
+ /** How the action's input is sent: plainly, or performed for viewers. */
20
+ readonly style: Style;
21
+ /**
22
+ * Time the style spends showing the input, such as a glide or typing at a person's pace, planned
23
+ * from now: the deadline moves back by as much, so presentation has a budget of its own.
24
+ */
25
+ readonly present: (millis: number) => Effect.Effect<void>;
26
+ /** The action's own input, such as a press, key or wheel, has reached the page. */
27
+ readonly sent: Effect.Effect<void>;
28
+ /** Preparatory input, such as the pointer travelling to a target, has reached the page. */
29
+ readonly touched: Effect.Effect<void>;
30
+ readonly at: (point: Point) => Effect.Effect<void>;
31
+ /** What the input acts on, and for a drag where it ends, as the page names them now. */
32
+ readonly on: (subject: Named, to?: Named) => Effect.Effect<void>;
33
+ /** The action's text is not bound for a secret field, so its record may keep it. */
34
+ readonly reveal: Effect.Effect<void>;
35
+ readonly input: Replies.Run;
36
+ }
37
+
38
+ /** What the page names a target: a resolved one, or the field that has focus. */
39
+ type Named = Pick<ResolvedTarget, "role" | "name" | "tag" | "context" | "box">;
40
+
41
+ const subjectOf = (target: Named) =>
42
+ new Subject({ role: target.role, name: target.name, tag: target.tag, context: target.context });
43
+
44
+ /** What an action records of what it acted on: subjects, and a point target's box. */
45
+ const actedOn = (subject: Named, to?: Named) => ({
46
+ subject: subjectOf(subject),
47
+ box: subject.box,
48
+ ...(to === undefined ? {} : { to: subjectOf(to) }),
49
+ });
50
+
51
+ /** A subject as span attributes under `prefix`, without a role it does not have. */
52
+ const subjectAttributes = (prefix: string, subject: Subject | undefined) =>
53
+ subject === undefined
54
+ ? {}
55
+ : {
56
+ ...(subject.role === null ? {} : { [`${prefix}.role`]: subject.role }),
57
+ [`${prefix}.name`]: subject.name,
58
+ [`${prefix}.tag`]: subject.tag,
59
+ };
60
+
61
+ /** What an action's record keeps of its call. */
62
+ export interface Call {
63
+ readonly target?: string | undefined;
64
+ /** The rest of what the caller asked, recorded so a plan can ask it again. */
65
+ readonly options?: ActionOptions | undefined;
66
+ readonly text?: string | undefined;
67
+ /** The text may be a secret: it is recorded only once the action reveals that it is not. */
68
+ readonly secret?: boolean | undefined;
69
+ /** False for navigation, which sends no input. */
70
+ readonly input?: boolean | undefined;
71
+ /** False for the pointer's travel ahead of an action, which is no action of its own. */
72
+ readonly recorded?: boolean | undefined;
73
+ }
74
+
75
+ const timedOut = (operation: string, timeout: Duration.Duration) =>
76
+ failWith(operation, new Timeout({ millis: Duration.toMillis(timeout) }));
77
+
78
+ /**
79
+ * Fail `effect` with `Timeout` once `timeout` has passed, and as much later again as `presented`
80
+ * says presentation has planned meanwhile.
81
+ */
82
+ const deadline =
83
+ (operation: string, timeout: Duration.Duration, now: () => number, presented: () => number) =>
84
+ <A>(effect: Effect.Effect<A, BrowserError>) =>
85
+ Effect.suspend(() => {
86
+ const due = now() + Duration.toMillis(timeout);
87
+
88
+ const expiry: Effect.Effect<never, BrowserError> = Effect.suspend(() => {
89
+ const left = due + presented() - now();
90
+
91
+ return left <= 0
92
+ ? timedOut(operation, timeout)
93
+ : Effect.sleep(Duration.millis(left)).pipe(Effect.andThen(expiry));
94
+ });
95
+
96
+ return Effect.raceFirst(effect, expiry);
97
+ });
98
+
99
+ export const make = (page: PageContext, sender: Dispatch) => {
100
+ const { id, settings, mapping, lane, publish, now, noteInput, span, owned } = page;
101
+ const { inputClocks, flush } = sender;
102
+ const input = Replies.make();
103
+
104
+ // Record the whole operation, but never hold the page's turn or spend its action timeout while
105
+ // a policy is waiting. Validation binds approval to the document and targets it saw.
106
+ const perform = <A>(
107
+ name: string,
108
+ info: Call,
109
+ timeout: Duration.Duration,
110
+ prepare: Effect.Effect<PolicyPlan, BrowserError>,
111
+ body: (marks: InputMarks, approval: Approval | undefined) => Effect.Effect<A, BrowserError>,
112
+ style: Style = plain,
113
+ ): Effect.Effect<A, BrowserError> =>
114
+ Effect.gen(function* () {
115
+ const startedAt = now();
116
+ const sent = yield* Ref.make(false);
117
+ const at = yield* Ref.make(Option.none<Point>());
118
+ const acted = yield* Ref.make<Pick<Action, "subject" | "to" | "box">>({});
119
+
120
+ // The page may react to preparatory input, as to script changes such as a selection, so
121
+ // paint from before either is not current; only the action's own input can have given it
122
+ // effect.
123
+ const touch = Effect.sync(noteInput);
124
+
125
+ // A failure before the action checks its field, a denial included, records no text.
126
+ let revealed = info.secret !== true;
127
+ // The time the style has planned to show the input, which the deadline leaves out.
128
+ let presented = 0;
129
+
130
+ const marks = {
131
+ style,
132
+ present: (millis: number) =>
133
+ Effect.sync(() => {
134
+ presented += Math.max(0, millis);
135
+ }),
136
+ sent: Ref.set(sent, true).pipe(Effect.andThen(touch)),
137
+ touched: touch,
138
+ at: (point: Point) => Ref.set(at, Option.some(point)),
139
+ on: (subject: Named, to?: Named) => Ref.set(acted, actedOn(subject, to)),
140
+ reveal: Effect.sync(() => {
141
+ revealed = true;
142
+ }),
143
+ };
144
+
145
+ // Admission waits only on this page's own unresolved replies, in its turn, so a stalled page
146
+ // cannot delay input on other pages. Input never waits for the clock mapping: until a
147
+ // capture has measured one, it keeps Chromium's own receipt time.
148
+ let estimate: BrowserClock.Estimate | undefined;
149
+
150
+ const admit = input.idle.pipe(
151
+ Effect.tap(() =>
152
+ Effect.sync(() => {
153
+ estimate = (info.input ?? true) ? mapping.latest() : undefined;
154
+ }),
155
+ ),
156
+ );
157
+
158
+ const useInput = <Value>(action: (run: Replies.Run) => Effect.Effect<Value, BrowserError>) =>
159
+ input.begin.pipe(
160
+ // A capture can recalibrate while input is running. One run, including delayed cleanup
161
+ // releases, keeps one mapping so epoch stamps cannot jump backwards during a stroke.
162
+ Effect.tap((run) =>
163
+ Effect.sync(() => {
164
+ if (estimate !== undefined) inputClocks.set(run, estimate);
165
+ }),
166
+ ),
167
+ Effect.flatMap((run) =>
168
+ action(run).pipe(
169
+ Effect.matchEffect({
170
+ onFailure: (error) =>
171
+ run.close.pipe(
172
+ Effect.andThen(flush(name, run).pipe(Effect.ignore)),
173
+ Effect.andThen(Effect.fail(error)),
174
+ ),
175
+ onSuccess: (value) =>
176
+ run.close.pipe(Effect.andThen(flush(name, run)), Effect.as(value)),
177
+ }),
178
+ // The finalizer only submits missing releases. It cannot wait forever for a
179
+ // disconnected peer; the page retains those replies and gates its next action.
180
+ Effect.ensuring(run.close.pipe(Effect.andThen(page.openings.clear))),
181
+ ),
182
+ ),
183
+ );
184
+
185
+ // How long its turn and the page's replies kept the action waiting, once it had both.
186
+ let queuedMillis: number | undefined;
187
+
188
+ // The page's turn and its earlier input's replies are waited for within the action's
189
+ // deadline, undispatched: a turn not given by then is `Busy`, and replies not answered by
190
+ // then a `Timeout`. The turn starts a full deadline of its own, so a wait never truncates
191
+ // input under way.
192
+ const dispatch = <Value>(action: Effect.Effect<Value, BrowserError>) =>
193
+ Effect.suspend(() => {
194
+ const asked = now();
195
+
196
+ return lane.write(
197
+ name,
198
+ timeout,
199
+ )(
200
+ Effect.suspend(() =>
201
+ admit.pipe(
202
+ Effect.timeoutOrElse({
203
+ duration: Duration.millis(
204
+ Math.max(0, Duration.toMillis(timeout) - (now() - asked)),
205
+ ),
206
+ orElse: () => timedOut(name, timeout),
207
+ }),
208
+ ),
209
+ ).pipe(
210
+ Effect.tap(() =>
211
+ Effect.sync(() => {
212
+ queuedMillis = Math.round(now() - asked);
213
+ }),
214
+ ),
215
+ Effect.andThen(deadline(name, timeout, now, () => presented)(action)),
216
+ ),
217
+ );
218
+ });
219
+
220
+ const guard = settings.guard;
221
+
222
+ // Only an approval needs binding: without a guard, input goes straight to the page.
223
+ const run =
224
+ guard === undefined
225
+ ? dispatch(useInput((run) => body({ ...marks, input: run }, undefined)))
226
+ : Effect.gen(function* () {
227
+ const plan = yield* lane
228
+ .read(name)(deadline(name, settings.actionTimeout, now, () => 0)(prepare))
229
+ .pipe(span("Page.prepare", {}, "Debug"));
230
+
231
+ // A hold lasts as long as this span; a judge's model call is its child.
232
+ yield* guard(plan.request).pipe(
233
+ Effect.mapError((reason) => undispatched(name, reason)),
234
+ Effect.timeoutOrElse({
235
+ duration: settings.policyTimeout,
236
+ orElse: () =>
237
+ failWith(
238
+ name,
239
+ new PolicyTimeout({ millis: Duration.toMillis(settings.policyTimeout) }),
240
+ ),
241
+ }),
242
+ span("Page.guard"),
243
+ );
244
+
245
+ return yield* dispatch(
246
+ useInput((run) =>
247
+ plan.validate.pipe(
248
+ Effect.flatMap((approval) => body({ ...marks, input: run }, approval)),
249
+ ),
250
+ ),
251
+ );
252
+ });
253
+
254
+ const record = info.recorded === false ? () => 0 : publish;
255
+
256
+ // Record the outcome even when the caller interrupts: its input may already be in the page.
257
+ return yield* Effect.uninterruptibleMask((restore) =>
258
+ Effect.gen(function* () {
259
+ const exit = yield* Effect.exit(restore(run));
260
+ const dispatched = yield* Ref.get(sent);
261
+ const point = yield* Ref.get(at);
262
+ const { subject, to, box } = yield* Ref.get(acted);
263
+ const failure = Exit.isFailure(exit) ? Exit.findErrorOption(exit) : Option.none();
264
+
265
+ // What the Action records, less its text: only a revealed text's length.
266
+ yield* Effect.annotateCurrentSpan({
267
+ dispatched,
268
+ ...(queuedMillis === undefined ? {} : { queuedMillis }),
269
+ ...(info.text !== undefined && revealed ? { chars: info.text.length } : {}),
270
+ ...subjectAttributes("subject", subject),
271
+ ...subjectAttributes("to", to),
272
+ ...Option.match(failure, {
273
+ onNone: () => ({}),
274
+ onSome: (error) => ({ "error.type": error.reason._tag }),
275
+ }),
276
+ });
277
+
278
+ record(
279
+ new Action({
280
+ at: now(),
281
+ startedAt,
282
+ page: id,
283
+ name,
284
+ correlation: yield* Correlation,
285
+ target: info.target,
286
+ options: Exit.isSuccess(exit) ? info.options : undefined,
287
+ subject,
288
+ to,
289
+ box,
290
+ text:
291
+ info.text === undefined ? undefined : revealed ? info.text.slice(0, 200) : redacted,
292
+ x: Option.getOrUndefined(Option.map(point, (p) => p.x)),
293
+ y: Option.getOrUndefined(Option.map(point, (p) => p.y)),
294
+ ok: Exit.isSuccess(exit),
295
+ dispatched,
296
+ error: Option.match(failure, {
297
+ onNone: () => (Exit.hasInterrupts(exit) ? "interrupted" : undefined),
298
+ onSome: (error) => error.message,
299
+ }),
300
+ }),
301
+ );
302
+ if (Exit.isSuccess(exit)) return exit.value;
303
+ if (dispatched)
304
+ return yield* Exit.mapError(exit, (error) =>
305
+ error.dispatched
306
+ ? error
307
+ : new BrowserError({
308
+ operation: error.operation,
309
+ reason: error.reason,
310
+ dispatched: true,
311
+ }),
312
+ );
313
+
314
+ return yield* exit;
315
+ }),
316
+ );
317
+ }).pipe(span(`Page.${name}`, info.target === undefined ? {} : { target: info.target }), owned);
318
+
319
+ return perform;
320
+ };
321
+
322
+ export type Perform = ReturnType<typeof make>;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The pointer's travel and the wheel, as the action's style sends them. Travel follows a motion
3
+ * plan, published before its first move, from where viewers last saw the pointer when it is
4
+ * shown, and a wheel scroll goes in the style's steps.
5
+ */
6
+ import { Duration, Effect, MutableRef, Option, Schema } from "effect";
7
+
8
+ import { InvalidRequest } from "../../BrowserError.ts";
9
+ import { CursorChanged, TrackPerformed, TrackPlanned } from "../../BrowserEvent.ts";
10
+ import * as Motion from "../../Motion.ts";
11
+ import type { Point } from "../../Page.ts";
12
+ import { failWith, type PageContext } from "../page/context.ts";
13
+ import type { Viewport } from "../page/viewport.ts";
14
+ import type { Dispatch } from "./dispatch.ts";
15
+ import type { InputMarks } from "./perform.ts";
16
+
17
+ export const make = (page: PageContext, dispatch: Dispatch, viewport: Viewport) => {
18
+ const { id, pointer, publish, now, span } = page;
19
+ const { inputClocks, inputCall, dispatchMouse, sendMouse } = dispatch;
20
+
21
+ // Travel toward a press only prepares the action; a hover is its travel, and a drag holds the
22
+ // button it has already pressed.
23
+ const moveTo = (
24
+ operation: string,
25
+ marks: InputMarks,
26
+ to: Point,
27
+ cursor?: string,
28
+ travel: "approach" | "hover" | "drag" = "approach",
29
+ ) =>
30
+ Effect.gen(function* () {
31
+ const dragging = travel === "drag";
32
+ const { style } = marks;
33
+ const drawn = style.pointer ?? pointer;
34
+ const size = yield* viewport.viewportFor(operation);
35
+
36
+ const previous = Option.getOrElse(MutableRef.get(drawn), () => ({
37
+ x: Math.round(size.width / 2),
38
+ y: Math.round(size.height / 2),
39
+ }));
40
+
41
+ const from = {
42
+ x: Math.max(0, Math.min(size.width - 1, previous.x)),
43
+ y: Math.max(0, Math.min(size.height - 1, previous.y)),
44
+ };
45
+
46
+ const planned = yield* style.glide(from, to, dragging);
47
+
48
+ // A custom planner is a boundary. Decoding reads each sample once into a fresh copy and
49
+ // checks that copy, so neither accessors nor later mutation can change what is admitted.
50
+ const invalid = failWith(
51
+ operation,
52
+ new InvalidRequest({
53
+ detail: "motion must be a bounded, ordered schedule ending at the target",
54
+ }),
55
+ );
56
+
57
+ const samples = yield* Schema.decodeEffect(Motion.Plan)(planned).pipe(
58
+ Effect.catch(() => invalid),
59
+ );
60
+
61
+ if (samples.at(-1)?.x !== to.x || samples.at(-1)?.y !== to.y) return yield* invalid;
62
+ yield* Effect.annotateCurrentSpan("samples", samples.length);
63
+ yield* marks.present(samples.at(-1)?.afterMillis ?? 0);
64
+ const run = marks.input;
65
+
66
+ // Admit the whole motion before starting its clock. Dense original samples must not be
67
+ // stretched by per-sample reply backpressure; unsent reservations belong to this run.
68
+ yield* inputCall(operation, run.reserveMotion(samples.length));
69
+ MutableRef.set(pointer, Option.some(from));
70
+ const estimate = inputClocks.get(run);
71
+ const at = now();
72
+ let dispatched = 0;
73
+ let last = from;
74
+
75
+ // The complete plan exists before its first move. A canceled performer always clips
76
+ // that future to the prefix it actually submitted, including interruption during sleep.
77
+ yield* Effect.acquireUseRelease(
78
+ Effect.sync(() => publish(new TrackPlanned({ at, page: id, from, samples }))),
79
+ () =>
80
+ Effect.gen(function* () {
81
+ for (const sample of samples) {
82
+ const remaining = at + sample.afterMillis - now();
83
+
84
+ if (remaining > 0) yield* Effect.sleep(Duration.millis(remaining));
85
+ yield* travel === "approach" ? marks.touched : marks.sent;
86
+ yield* inputCall(
87
+ operation,
88
+ run.send(() =>
89
+ dispatchMouse(
90
+ {
91
+ type: "mouseMoved",
92
+ x: sample.x,
93
+ y: sample.y,
94
+ button: dragging ? "left" : "none",
95
+ ...(dragging ? { buttons: 1 } : {}),
96
+ },
97
+ estimate,
98
+ () => {
99
+ dispatched++;
100
+ last = { x: sample.x, y: sample.y };
101
+ MutableRef.set(drawn, Option.some(last));
102
+ if (dispatched === samples.length && cursor !== undefined)
103
+ publish(new CursorChanged({ at: now(), page: id, cursor }));
104
+ },
105
+ ),
106
+ ),
107
+ );
108
+ }
109
+ }),
110
+ (plan) =>
111
+ Effect.sync(() =>
112
+ publish(
113
+ new TrackPerformed({
114
+ at: now(),
115
+ page: id,
116
+ plan,
117
+ dispatched,
118
+ ...last,
119
+ complete: dispatched === samples.length,
120
+ }),
121
+ ),
122
+ ),
123
+ );
124
+ }).pipe(span("Page.move", {}, "Debug"));
125
+
126
+ const wheel = Effect.fnUntraced(function* (
127
+ operation: string,
128
+ marks: InputMarks,
129
+ point: Point,
130
+ dx: number,
131
+ dy: number,
132
+ ) {
133
+ const steps = yield* marks.style.wheel(dx, dy);
134
+ const at = now();
135
+
136
+ yield* marks.present(steps.at(-1)?.afterMillis ?? 0);
137
+ for (const step of steps) {
138
+ const remaining = at + step.afterMillis - now();
139
+
140
+ if (remaining > 0) yield* Effect.sleep(Duration.millis(remaining));
141
+ yield* sendMouse(operation, marks.input, {
142
+ type: "mouseWheel",
143
+ ...point,
144
+ deltaX: step.dx,
145
+ deltaY: step.dy,
146
+ });
147
+ }
148
+ });
149
+
150
+ return { moveTo, wheel };
151
+ };
152
+
153
+ export type Pointer = ReturnType<typeof make>;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Bounded input replies owned by the page, including after an action is interrupted.
3
+ * Canceling a caller cannot cancel a protocol command already sent. The next action waits for
4
+ * those replies, while a stopped run releases only held keys or buttons whose release was never submitted.
5
+ */
6
+ import { Data, Effect } from "effect";
7
+
8
+ import { maximumSamples } from "../../Motion.ts";
9
+
10
+ export const capacity = 64;
11
+
12
+ export class InputFailure extends Data.TaggedError("InputFailure")<{
13
+ readonly cause: unknown;
14
+ }> {}
15
+
16
+ export interface Run {
17
+ /** Reserve a complete stroke, including its releases, before sending its first event. */
18
+ readonly reserve: (commands: number) => Effect.Effect<void, InputFailure>;
19
+ /** Admit every sample before a motion starts its published clock; ordinary input stays bounded separately. */
20
+ readonly reserveMotion: (commands: number) => Effect.Effect<void, InputFailure>;
21
+ readonly send: (command: () => Promise<unknown>) => Effect.Effect<void, InputFailure>;
22
+ readonly down: (
23
+ key: string,
24
+ press: () => Promise<unknown>,
25
+ release: () => Promise<unknown>,
26
+ ) => Effect.Effect<void, InputFailure>;
27
+ readonly up: (key: string) => Effect.Effect<void, InputFailure>;
28
+ readonly drain: Effect.Effect<void, InputFailure>;
29
+ /** Stop future input and submit missing releases without waiting indefinitely for replies. */
30
+ readonly close: Effect.Effect<void>;
31
+ }
32
+
33
+ export const make = () => {
34
+ const outstanding = new Set<Promise<void>>();
35
+
36
+ // A timed-out action keeps capacity until its real replies arrive. Waiting for them sends
37
+ // nothing, so cancellation here cannot leave a detached continuation that later types.
38
+ const idle = Effect.gen(function* () {
39
+ while (outstanding.size > 0) yield* Effect.promise(() => Promise.all(outstanding));
40
+ });
41
+
42
+ const begin = Effect.gen(function* () {
43
+ yield* idle;
44
+ const pending = new Set<Promise<void>>();
45
+ const held: Array<{ readonly key: string; readonly release: () => Promise<unknown> }> = [];
46
+ let failure: InputFailure | undefined;
47
+ let reserved = 0;
48
+ let closed = false;
49
+
50
+ const requireOpen = Effect.suspend(() =>
51
+ closed
52
+ ? Effect.fail(new InputFailure({ cause: new Error("the input run has stopped") }))
53
+ : Effect.void,
54
+ );
55
+
56
+ const requireSuccess = Effect.suspend(() =>
57
+ failure === undefined ? Effect.void : Effect.fail(failure),
58
+ );
59
+
60
+ const submit = (command: () => Promise<unknown>) => {
61
+ if (reserved < 1) throw new Error("input must reserve capacity before dispatch");
62
+ reserved--;
63
+ let response: Promise<unknown>;
64
+
65
+ try {
66
+ response = command();
67
+ } catch (cause) {
68
+ failure ??= new InputFailure({ cause });
69
+
70
+ return;
71
+ }
72
+
73
+ // Every rejection is observed immediately; a failure does not abandon later replies.
74
+ const reply = response.then(
75
+ () => {
76
+ pending.delete(reply);
77
+ outstanding.delete(reply);
78
+ },
79
+ (cause: unknown) => {
80
+ failure ??= new InputFailure({ cause });
81
+ pending.delete(reply);
82
+ outstanding.delete(reply);
83
+ },
84
+ );
85
+
86
+ pending.add(reply);
87
+ outstanding.add(reply);
88
+ };
89
+
90
+ const reserve = (commands: number, maximum: number, limit: number) =>
91
+ Effect.gen(function* () {
92
+ yield* requireOpen;
93
+ yield* requireSuccess;
94
+ if (!Number.isInteger(commands) || commands < 1 || commands > maximum)
95
+ return yield* Effect.die(new Error("invalid input reservation"));
96
+ while (outstanding.size + reserved + commands > limit) {
97
+ yield* Effect.promise(() => Promise.race(outstanding));
98
+ yield* requireOpen;
99
+ yield* requireSuccess;
100
+ }
101
+ reserved += commands;
102
+ });
103
+
104
+ const run: Run = {
105
+ reserve: (commands) => reserve(commands, capacity, capacity),
106
+ // A dense stroke cannot wait for replies midway without changing its published timing.
107
+ // Its finite reservation also leaves room for the ordinary pool's already-owned releases.
108
+ reserveMotion: (commands) => reserve(commands, maximumSamples, capacity + maximumSamples),
109
+ send: (command) =>
110
+ Effect.gen(function* () {
111
+ yield* requireOpen;
112
+ yield* requireSuccess;
113
+ yield* Effect.sync(() => submit(command));
114
+ yield* requireSuccess;
115
+ }),
116
+ down: (key, press, release) =>
117
+ Effect.gen(function* () {
118
+ yield* requireOpen;
119
+ yield* requireSuccess;
120
+ yield* Effect.sync(() => {
121
+ held.push({ key, release });
122
+ submit(press);
123
+ });
124
+ yield* requireSuccess;
125
+ }),
126
+ up: (key) =>
127
+ Effect.gen(function* () {
128
+ yield* requireOpen;
129
+ yield* Effect.sync(() => {
130
+ const index = held.findLastIndex((entry) => entry.key === key);
131
+ const entry = held[index];
132
+
133
+ if (entry === undefined) return;
134
+ // Submission owns the release, even if its reply fails. Retrying an uncertain
135
+ // release could run page handlers twice, so cleanup never sends it again.
136
+ held.splice(index, 1);
137
+ submit(entry.release);
138
+ });
139
+ yield* requireSuccess;
140
+ }),
141
+ drain: Effect.gen(function* () {
142
+ while (pending.size > 0) yield* Effect.promise(() => Promise.all(pending));
143
+ yield* requireSuccess;
144
+ }),
145
+ close: Effect.sync(() => {
146
+ if (closed) return;
147
+ closed = true;
148
+ for (const entry of held.toReversed()) submit(entry.release);
149
+ held.length = 0;
150
+ reserved = 0;
151
+ }),
152
+ };
153
+
154
+ return run;
155
+ });
156
+
157
+ /** `idle` lets an action wait, in its page's turn, for the page's earlier replies. */
158
+ return { idle, begin };
159
+ };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The wait after input: one task and one frame in the page, in one call, then a document only if
3
+ * one committed meanwhile, until it is parsed. Chromium answers a call to a page that a link, a form
4
+ * or a script's timer has asked to navigate only once the new document commits, or the navigation
5
+ * stops without one, as on a 204, so the call spans the navigation the input asked for. A
6
+ * `pushState` asks for no document, and a navigation a handler starts after a fetch answers is the
7
+ * next look's to see. A tab the input opened waits for the browser's registration too.
8
+ */
9
+ import { Deferred, Duration, Effect, Exit } from "effect";
10
+
11
+ import { type Bridge, scriptCall } from "../page/bridge.ts";
12
+ import { contextGone, type PageContext } from "../page/context.ts";
13
+
14
+ // A document still loading after this, or a closed page, is the next look's to show.
15
+ const settling = Duration.seconds(5);
16
+
17
+ export const make = (page: PageContext, bridge: Bridge) => {
18
+ const document = () => bridge.frameTag().document;
19
+ // The latest document whose DOMContentLoaded came, and what wakes a wait for the next.
20
+ let parsed = -1;
21
+ let changed = Deferred.makeUnsafe<void>();
22
+
23
+ page.cdp.on("Page.domContentEventFired", () => {
24
+ parsed = document();
25
+ Deferred.doneUnsafe(changed, Exit.void);
26
+ changed = Deferred.makeUnsafe<void>();
27
+ });
28
+
29
+ /** The page's document before input, which `settle` measures from. */
30
+ const mark = () => ({ document: document(), openings: page.openings.mark() });
31
+
32
+ /** Wait for the page to settle after input sent since `before`. */
33
+ const settle = (operation: string, before: ReturnType<typeof mark>) =>
34
+ Effect.gen(function* () {
35
+ // A commit ends the call's world; the loop then waits for that document.
36
+ yield* bridge
37
+ .evaluate(operation, scriptCall("settle"))
38
+ .pipe(Effect.catchIf(contextGone, () => Effect.void));
39
+ while (document() > before.document && parsed < document()) yield* Deferred.await(changed);
40
+ }).pipe(
41
+ Effect.timeoutOrElse({ duration: settling, orElse: () => Effect.fail("unsettled") }),
42
+ Effect.catch(() => Effect.annotateCurrentSpan("settled", false)),
43
+ Effect.andThen(page.openings.settle(before.openings)),
44
+ page.span("Page.settle", {}, "Debug"),
45
+ );
46
+
47
+ return { mark, settle };
48
+ };
49
+
50
+ export type Settle = ReturnType<typeof make>;