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

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 +705 -1184
  2. package/dist/Browser-CM65Uo7f.mjs +437 -0
  3. package/dist/Browser-CM65Uo7f.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-CNNOU1Tx.d.mts +112 -0
  44. package/dist/Plan-Dtrb-8im.mjs +311 -0
  45. package/dist/Plan-Dtrb-8im.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 +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-Banfm_zc.mjs +5097 -0
  73. package/dist/page-Banfm_zc.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 +350 -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 +607 -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,195 @@
1
+ /**
2
+ * Input performed for viewers. A presenter owns one drawn pointer and a pace; `view(page)` is the
3
+ * page with its actions performed: each waits as long as a person reacts, the pointer glides from
4
+ * where viewers last saw it, on whichever page, a field is clicked before typing, keys go at the
5
+ * pace's words a minute and the wheel turns in bursts, out to a target out of view. One view acts at
6
+ * a time, as one hand moves one pointer, and its presentation time is outside `actionTimeout`. The
7
+ * page itself stays plain, and plain input never waits for a view.
8
+ *
9
+ * ```ts
10
+ * const presenter = yield* Presentation.make({ motion: yield* HumanStrokes.motion })
11
+ * const performed = presenter.view(page)
12
+ * yield* performed.aim("e12") // as the target appears in a streamed tool call
13
+ * yield* performed.click("e12") // completes the glide, then clicks
14
+ * ```
15
+ *
16
+ * @since 0.3.0
17
+ */
18
+ import { Clock, Duration, Effect, Fiber, MutableRef, Option, Semaphore } from "effect";
19
+
20
+ import type { BrowserError } from "./BrowserError.ts";
21
+ import * as Style from "./internal/input/style.ts";
22
+ import { type Internals, internalsOf, viewOf } from "./internal/page/page.ts";
23
+ import * as Motion from "./Motion.ts";
24
+ import type { Page, Point, Target } from "./Page.ts";
25
+
26
+ /** How long a person waits before acting, and how fast they type. */
27
+ export interface Pacing {
28
+ /** The usual wait before an action that follows an expected change, as the next field of a form. */
29
+ readonly expected: Duration.Input;
30
+ /** The usual wait after the page moved to a new document. */
31
+ readonly surprise: Duration.Input;
32
+ /** The usual wait before a page's first action, or one after an action on another page. */
33
+ readonly unrelated: Duration.Input;
34
+ /** Typing speed, a word being five characters. */
35
+ readonly wordsPerMinute: number;
36
+ }
37
+
38
+ /**
39
+ * The research's medians: 280 ms to react to an expected change, 600 ms to a surprise and 1 second
40
+ * between unrelated actions, each drawn around its median, and typing at 70 words per minute.
41
+ */
42
+ export const human: Pacing = {
43
+ expected: "280 millis",
44
+ surprise: "600 millis",
45
+ unrelated: "1 second",
46
+ wordsPerMinute: 70,
47
+ };
48
+
49
+ export interface Options {
50
+ /** Defaults to `human`. */
51
+ readonly pacing?: Pacing | undefined;
52
+ /** The pointer's paths. Defaults to `Motion.lognormal`. */
53
+ readonly motion?: Motion.Service | undefined;
54
+ }
55
+
56
+ /** A page whose actions are performed, which can also start the pointer toward a target early. */
57
+ export interface View extends Page {
58
+ /**
59
+ * Start the glide toward `target` as soon as it is known, as in a model's streamed tool call. The
60
+ * next action waits for it when it acts on the same target, and otherwise stops it where it is.
61
+ * An aim presses nothing and records no action; under an input guard it does nothing.
62
+ */
63
+ readonly aim: (target: Target) => Effect.Effect<void>;
64
+ }
65
+
66
+ export interface Presenter {
67
+ /** `page`, a page a `Browser` opened, with its actions performed: the same view for the same page. */
68
+ readonly view: (page: Page) => View;
69
+ }
70
+
71
+ // Reactions spread as the research measured them: an expected change least.
72
+ const spread = { expected: 0.3, surprise: 0.4, unrelated: 0.4 };
73
+
74
+ const sameTarget = (left: Target, right: Target | undefined) =>
75
+ typeof left === "string" || typeof right !== "object"
76
+ ? left === right
77
+ : left.x === right.x && left.y === right.y;
78
+
79
+ /** A presenter, for the scope: its pointer, its pace and the aims it has started. */
80
+ export const make = Effect.fn("Presentation.make")(function* (options: Options = {}) {
81
+ const pacing = options.pacing ?? human;
82
+ const clock = yield* Clock.Clock;
83
+ const scope = yield* Effect.scope;
84
+ const now = () => Number(clock.monotonicTimeNanosUnsafe()) / 1e6;
85
+ const pointer = MutableRef.make(Option.none<Point>());
86
+ const hand = yield* Semaphore.make(1);
87
+
88
+ const style = Style.performed({
89
+ pointer,
90
+ motion: options.motion ?? Motion.lognormal,
91
+ wordsPerMinute: pacing.wordsPerMinute,
92
+ });
93
+
94
+ const medians = {
95
+ expected: Duration.toMillis(Duration.fromInputUnsafe(pacing.expected)),
96
+ surprise: Duration.toMillis(Duration.fromInputUnsafe(pacing.surprise)),
97
+ unrelated: Duration.toMillis(Duration.fromInputUnsafe(pacing.unrelated)),
98
+ };
99
+
100
+ // The latest performed action: its page, the document it began in, and when it ended.
101
+ let last: { readonly page: string; readonly document: number; readonly at: number } | undefined;
102
+
103
+ // A person reacts to what the last action left: an expected change, a new document, as a link
104
+ // opens, or another page altogether. Time already spent since it ended counts toward the wait.
105
+ const react = (page: Page, internals: Internals) =>
106
+ Effect.gen(function* () {
107
+ const kind =
108
+ last === undefined || last.page !== page.id
109
+ ? "unrelated"
110
+ : last.document === internals.document()
111
+ ? "expected"
112
+ : "surprise";
113
+
114
+ const left = (last?.at ?? now()) + (yield* Style.pause(medians[kind], spread[kind])) - now();
115
+
116
+ if (left > 0) yield* Effect.sleep(Duration.millis(left));
117
+ }).pipe(Effect.provideService(Clock.Clock, clock));
118
+
119
+ const views = new WeakMap<Page, View>();
120
+
121
+ const view = (page: Page): View => {
122
+ const known = views.get(page);
123
+
124
+ if (known !== undefined) return known;
125
+ const internals = internalsOf(page);
126
+
127
+ if (internals === undefined)
128
+ throw new TypeError("only a page that a Browser opened can be presented");
129
+ const { input } = internals;
130
+
131
+ let aiming:
132
+ | { readonly target: Target; readonly fiber: Fiber.Fiber<void, BrowserError> }
133
+ | undefined;
134
+
135
+ // An aim at the action's own target is completed; any other is stopped where it is.
136
+ const completeAim = (target: Target | undefined) =>
137
+ Effect.suspend(() => {
138
+ const current = aiming;
139
+
140
+ aiming = undefined;
141
+ if (current === undefined) return Effect.void;
142
+
143
+ return sameTarget(current.target, target)
144
+ ? Effect.asVoid(Fiber.await(current.fiber))
145
+ : Fiber.interrupt(current.fiber);
146
+ });
147
+
148
+ const perform =
149
+ (target: Target | undefined) =>
150
+ <A, E>(action: Effect.Effect<A, E>): Effect.Effect<A, E> =>
151
+ completeAim(target).pipe(
152
+ Effect.andThen(
153
+ hand.withPermits(1)(
154
+ Effect.gen(function* () {
155
+ yield* react(page, internals);
156
+ const document = internals.document();
157
+
158
+ return yield* action.pipe(
159
+ Effect.ensuring(
160
+ Effect.sync(() => {
161
+ last = { page: page.id, document, at: now() };
162
+ }),
163
+ ),
164
+ );
165
+ }),
166
+ ),
167
+ ),
168
+ );
169
+
170
+ const performed: View = viewOf(page, {
171
+ ...page,
172
+ click: (target, clickOptions) => perform(target)(input.click(target, clickOptions, style)),
173
+ hover: (target) => perform(target)(input.hover(target, style)),
174
+ drag: (from, to) => perform(from)(input.drag(from, to, style)),
175
+ type: (text, typeOptions) => perform(typeOptions?.into)(input.type(text, typeOptions, style)),
176
+ press: (keys, pressOptions) => perform(undefined)(input.press(keys, pressOptions, style)),
177
+ scroll: (scrollOptions) => perform(scrollOptions?.at)(input.scroll(scrollOptions, style)),
178
+ select: (ref, values) => perform(ref)(input.select(ref, values)),
179
+ aim: (target) =>
180
+ Effect.gen(function* () {
181
+ yield* completeAim(undefined);
182
+ aiming = {
183
+ target,
184
+ fiber: yield* hand.withPermits(1)(input.aim(target, style)).pipe(Effect.forkIn(scope)),
185
+ };
186
+ }),
187
+ });
188
+
189
+ views.set(page, performed);
190
+
191
+ return performed;
192
+ };
193
+
194
+ return { view } satisfies Presenter;
195
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * A compact, model-readable outline of what a page shows.
3
+ *
4
+ * Each control gets a ref such as `e12`. A ref stays valid while its element is on the page, and
5
+ * refs are never reused within a page, so a ref from an old snapshot fails as stale rather than
6
+ * naming a different element.
7
+ *
8
+ * @since 0.3.0
9
+ */
10
+ import { Schema } from "effect";
11
+
12
+ /** A control the outline shows, as a value: its ref, what it is and what it holds. */
13
+ export class Control extends Schema.Class<Control>("effect-browser/Control")({
14
+ ref: Schema.String,
15
+ /** Its role, such as `button` or `textbox`, or `clickable` for an element without one. */
16
+ kind: Schema.String,
17
+ /** Its accessible name. */
18
+ name: Schema.String,
19
+ /** What it holds: a field's text, `••••` for a secret one, or a select's chosen labels. */
20
+ value: Schema.String,
21
+ /** A select's option labels, at most 64. */
22
+ options: Schema.optional(Schema.Array(Schema.String)),
23
+ /** How many options a select has, when it has options. */
24
+ optionCount: Schema.optional(Schema.Int),
25
+ disabled: Schema.optional(Schema.Boolean),
26
+ /** Whether it is checked, for what can be checked. */
27
+ checked: Schema.optional(Schema.Boolean),
28
+ /** True for a field that takes typed text. */
29
+ editable: Schema.optional(Schema.Boolean),
30
+ }) {}
31
+
32
+ export class Snapshot extends Schema.Class<Snapshot>("effect-browser/Snapshot")({
33
+ url: Schema.String,
34
+ title: Schema.String,
35
+ /** One line per control, heading or text block, indented by landmark. */
36
+ text: Schema.String,
37
+ /** True when `text` was cut at `maxChars`. */
38
+ truncated: Schema.Boolean,
39
+ /**
40
+ * Parts of the page left out because they lie above or below the viewport, each an element out
41
+ * of view with all it holds (viewport snapshots only).
42
+ */
43
+ above: Schema.Finite,
44
+ below: Schema.Finite,
45
+ viewport: Schema.Struct({ width: Schema.Finite, height: Schema.Finite }),
46
+ scroll: Schema.Struct({ y: Schema.Finite, height: Schema.Finite }),
47
+ /** The controls the outline shows, in its order. */
48
+ controls: Schema.Array(Control),
49
+ }) {
50
+ /** The snapshot as one block of text for a model: header, outline and what was left out. */
51
+ get rendered(): string {
52
+ const position =
53
+ this.scroll.height > this.viewport.height
54
+ ? `, scrolled to ${this.scroll.y} of ${this.scroll.height - this.viewport.height}px`
55
+ : "";
56
+
57
+ const omitted: Array<string> = [];
58
+
59
+ if (this.above > 0) omitted.push(`${this.above} parts above`);
60
+ if (this.below > 0) omitted.push(`${this.below} parts below`);
61
+
62
+ const footer = [
63
+ ...(omitted.length > 0 ? [`(${omitted.join(", ")} the viewport)`] : []),
64
+ ...(this.truncated ? ["(cut short: narrow it with query)"] : []),
65
+ ];
66
+
67
+ return [
68
+ `Page: ${this.title === "" ? "(untitled)" : this.title}`,
69
+ `URL: ${this.url}`,
70
+ `Viewport: ${this.viewport.width}x${this.viewport.height}${position}`,
71
+ this.text === "" ? "(nothing readable in view)" : this.text,
72
+ ...footer,
73
+ ].join("\n");
74
+ }
75
+ }
76
+
77
+ export interface SnapshotOptions {
78
+ /** Read the whole page instead of the viewport. Defaults to false. */
79
+ readonly full?: boolean | undefined;
80
+ /** Keep only lines containing this text (case-insensitive). */
81
+ readonly query?: string | undefined;
82
+ /**
83
+ * Read only inside the elements this CSS selector matches, such as `form` or `#results`. A
84
+ * selector that matches nothing reads nothing; one that is not CSS is an `InvalidRequest`.
85
+ */
86
+ readonly within?: string | undefined;
87
+ /** Bound on the outline's length. Defaults to 12,000 characters. */
88
+ readonly maxChars?: number | undefined;
89
+ }
package/src/Stage.ts ADDED
@@ -0,0 +1,223 @@
1
+ /**
2
+ * The stage: whichever page's capture feeds a live output, switched between pages in one browser
3
+ * session or across two, each switch stamped with when it took effect on the frame clock. Make one
4
+ * per live output.
5
+ *
6
+ * `present(page, { at })` switches the output to `page` at `at`, host monotonic milliseconds on the
7
+ * clock that times frames, or at once. It starts the new page's capture ahead, waits for its first
8
+ * frame, then for any input already under way on the old page, and turns: from then on `frames`
9
+ * carries the new page's frames, its newest first, and the old page's capture stops. The captures
10
+ * overlap, so a switch shows no dark spell; on Browserbase two ran in one session at the frame
11
+ * rate of one. A first frame that does not come in time fails `present`, and the old page stays. A
12
+ * capture that fails restarts on the same page while its page and browser stand.
13
+ *
14
+ * What a live output does with frames stays the application's: a delay line, a liveness rule,
15
+ * redaction and repeating a held frame. A still page sends no frames until it changes, so the frame
16
+ * a switch begins with may be older than the frames before it; `Presented.at` says when it took
17
+ * effect.
18
+ *
19
+ * @since 0.3.0
20
+ */
21
+ import {
22
+ Clock,
23
+ Deferred,
24
+ Duration,
25
+ Effect,
26
+ Exit,
27
+ Fiber,
28
+ Option,
29
+ PubSub,
30
+ Schedule,
31
+ Schema,
32
+ Semaphore,
33
+ Stream,
34
+ } from "effect";
35
+
36
+ import { type BrowserError, consequence, InvalidRequest, Timeout } from "./BrowserError.ts";
37
+ import type { CaptureStats, Frame, ScreencastOptions } from "./Frame.ts";
38
+ import { failWith } from "./internal/page/context.ts";
39
+ import { internalsOf } from "./internal/page/page.ts";
40
+ import type { Page } from "./Page.ts";
41
+
42
+ export interface Options extends ScreencastOptions {
43
+ /** How long a switch waits for the new page's first frame. Defaults to 5 seconds. */
44
+ readonly firstFrame?: Duration.Input | undefined;
45
+ }
46
+
47
+ /** A switch that took effect. */
48
+ export class Presented extends Schema.Class<Presented>("effect-browser/Presented")({
49
+ page: Schema.String,
50
+ /**
51
+ * When it took effect, in host monotonic milliseconds on the frame clock: no earlier than the
52
+ * time asked for, the frames it followed or the new page's first.
53
+ */
54
+ at: Schema.Finite,
55
+ /** How long after the time asked for it took effect. */
56
+ latency: Schema.Finite,
57
+ }) {}
58
+
59
+ export interface Stage {
60
+ readonly present: (
61
+ page: Page,
62
+ options?: { readonly at?: number | undefined },
63
+ ) => Effect.Effect<Presented, BrowserError>;
64
+ /**
65
+ * The presented page's frames, each naming its page, session, document and address. A reader
66
+ * more than 16 frames behind loses the oldest.
67
+ */
68
+ readonly frames: Stream.Stream<Frame>;
69
+ readonly current: Effect.Effect<Option.Option<Page>>;
70
+ /**
71
+ * The presented page's capture counts over the latest `window`, at most a minute: its frames,
72
+ * their largest gap, and those filtered out, as another capture's or a crop's.
73
+ */
74
+ readonly stats: (options?: {
75
+ readonly window?: Duration.Input | undefined;
76
+ }) => Effect.Effect<Option.Option<CaptureStats>, BrowserError>;
77
+ }
78
+
79
+ /** A page's capture for the stage: its newest frame, and whether its frames go out. */
80
+ interface Feed {
81
+ readonly page: Page;
82
+ readonly first: Deferred.Deferred<void>;
83
+ latest: Frame | undefined;
84
+ live: boolean;
85
+ fiber: Fiber.Fiber<void, BrowserError> | undefined;
86
+ }
87
+
88
+ // A capture restarts unless its page or browser is gone, or it would fail the same way again.
89
+ const restartable = (error: BrowserError) => {
90
+ const { lost, repeat } = consequence(error);
91
+
92
+ return lost === "nothing" && repeat !== "pointless";
93
+ };
94
+
95
+ export const make = Effect.fn("Stage.make")(function* (options: Options = {}) {
96
+ const clock = yield* Clock.Clock;
97
+ const scope = yield* Effect.scope;
98
+ const now = () => Number(clock.monotonicTimeNanosUnsafe()) / 1e6;
99
+ const waited = Duration.fromInput(options.firstFrame ?? Duration.seconds(5));
100
+
101
+ if (Option.isNone(waited) || !Duration.isPositive(waited.value))
102
+ return yield* failWith(
103
+ "stage",
104
+ new InvalidRequest({ detail: "firstFrame must be a positive duration" }),
105
+ );
106
+ const firstFrame = waited.value;
107
+ const settings = { quality: options.quality, size: options.size };
108
+ const output = yield* PubSub.sliding<Frame>(16);
109
+ const switching = yield* Semaphore.make(1);
110
+ // What is on the stage, which only `present` changes, and the paint of the latest frame out.
111
+ let shown: { readonly feed: Feed; readonly presented: Presented } | undefined;
112
+ let painted = Number.NEGATIVE_INFINITY;
113
+
114
+ const feed = (page: Page) =>
115
+ Effect.gen(function* () {
116
+ const fed: Feed = {
117
+ page,
118
+ first: yield* Deferred.make<void>(),
119
+ latest: undefined,
120
+ live: false,
121
+ fiber: undefined,
122
+ };
123
+
124
+ fed.fiber = yield* page.screencast(settings).pipe(
125
+ Stream.runForEach((frame) =>
126
+ Effect.sync(() => {
127
+ fed.latest = frame;
128
+ if (fed.live) {
129
+ painted = frame.hostTime;
130
+ PubSub.publishUnsafe(output, frame);
131
+ }
132
+ // Last: a switch waiting for this frame may turn, and send it, at once.
133
+ Deferred.doneUnsafe(fed.first, Exit.void);
134
+ }),
135
+ ),
136
+ Effect.retry({ while: restartable, schedule: Schedule.spaced(Duration.millis(500)) }),
137
+ Effect.forkIn(scope),
138
+ );
139
+
140
+ return fed;
141
+ });
142
+
143
+ // No frame of it goes out once this returns, and its capture stops.
144
+ const stop = (fed: Feed) =>
145
+ Effect.suspend(() => {
146
+ fed.live = false;
147
+
148
+ return fed.fiber === undefined ? Effect.void : Fiber.interrupt(fed.fiber);
149
+ });
150
+
151
+ // The new page's first frame; its capture's own failure, or `Timeout`.
152
+ const firstOf = (fed: Feed) =>
153
+ Effect.raceFirst(
154
+ Deferred.await(fed.first),
155
+ fed.fiber === undefined ? Effect.never : Effect.andThen(Fiber.join(fed.fiber), Effect.never),
156
+ ).pipe(
157
+ Effect.timeoutOrElse({
158
+ duration: firstFrame,
159
+ orElse: () => failWith("present", new Timeout({ millis: Duration.toMillis(firstFrame) })),
160
+ }),
161
+ );
162
+
163
+ // A timer can fire a fraction of a millisecond early, so the wait looks again.
164
+ const until = (at: number): Effect.Effect<void> =>
165
+ Effect.suspend(() =>
166
+ at > now()
167
+ ? Effect.andThen(Effect.sleep(Duration.millis(Math.ceil(at - now()))), until(at))
168
+ : Effect.void,
169
+ );
170
+
171
+ const present: Stage["present"] = (page, presentOptions = {}) =>
172
+ switching.withPermits(1)(
173
+ Effect.gen(function* () {
174
+ const asked = presentOptions.at ?? now();
175
+ const old = shown;
176
+
177
+ if (old?.feed.page === page) return old.presented;
178
+ const fed = yield* feed(page);
179
+
180
+ yield* Effect.gen(function* () {
181
+ yield* firstOf(fed);
182
+ yield* until(asked);
183
+ // Input under way on the old page ends first, within its action timeout.
184
+ if (old !== undefined)
185
+ yield* Effect.ignore(internalsOf(old.feed.page)?.inputEnded ?? Effect.void);
186
+ }).pipe(Effect.onError(() => stop(fed)));
187
+
188
+ const presented = yield* Effect.sync(() => {
189
+ const frame = fed.latest;
190
+ const at = Math.max(asked, frame?.hostTime ?? asked, painted);
191
+
192
+ if (old !== undefined) old.feed.live = false;
193
+ fed.live = true;
194
+ if (frame !== undefined) {
195
+ painted = frame.hostTime;
196
+ PubSub.publishUnsafe(output, frame);
197
+ }
198
+ shown = {
199
+ feed: fed,
200
+ presented: new Presented({ page: page.id, at, latency: at - asked }),
201
+ };
202
+
203
+ return shown.presented;
204
+ });
205
+
206
+ if (old !== undefined) yield* stop(old.feed);
207
+
208
+ return presented;
209
+ }).pipe(Effect.provideService(Clock.Clock, clock)),
210
+ );
211
+
212
+ return {
213
+ present,
214
+ frames: Stream.fromPubSub(output),
215
+ current: Effect.sync(() => Option.fromNullishOr(shown?.feed.page)),
216
+ stats: (statsOptions = {}) =>
217
+ shown === undefined
218
+ ? Effect.succeedNone
219
+ : Effect.asSome(
220
+ shown.feed.page.captureStats({ window: statsOptions.window ?? "1 second" }),
221
+ ),
222
+ } satisfies Stage;
223
+ });