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

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 (204) hide show
  1. package/README.md +849 -1161
  2. package/dist/Browser-S4kvpmqa.mjs +605 -0
  3. package/dist/Browser-S4kvpmqa.mjs.map +1 -0
  4. package/dist/Browser.d.mts +127 -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-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-B5cjaUp5.mjs +618 -0
  39. package/dist/Page-B5cjaUp5.mjs.map +1 -0
  40. package/dist/Page-BlPjfibv.d.mts +595 -0
  41. package/dist/Page.d.mts +2 -0
  42. package/dist/Page.mjs +2 -0
  43. package/dist/Plan-BI8XGvDu.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-B8tUQELg.mjs +5073 -0
  73. package/dist/page-B8tUQELg.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 +666 -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 +130 -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 +386 -0
  113. package/src/internal/page/context.ts +312 -0
  114. package/src/internal/page/lane.ts +311 -0
  115. package/src/internal/page/navigation.ts +183 -0
  116. package/src/internal/page/openings.ts +87 -0
  117. package/src/internal/page/page.ts +165 -0
  118. package/src/internal/page/url.ts +100 -0
  119. package/src/internal/page/viewport.ts +43 -0
  120. package/src/internal/pictures/capture.ts +776 -0
  121. package/src/internal/pictures/clock.ts +141 -0
  122. package/src/internal/pictures/counts.ts +92 -0
  123. package/src/internal/pictures/pictures.ts +520 -0
  124. package/src/internal/pictures/transport.ts +242 -0
  125. package/src/internal/reading/choose.ts +100 -0
  126. package/src/internal/reading/context.inpage.ts +201 -0
  127. package/src/internal/reading/controls.inpage.ts +158 -0
  128. package/src/internal/reading/expect.inpage.ts +70 -0
  129. package/src/internal/reading/match.inpage.ts +92 -0
  130. package/src/internal/reading/names.inpage.ts +306 -0
  131. package/src/internal/reading/outline.inpage.ts +260 -0
  132. package/src/internal/reading/reading.ts +223 -0
  133. package/src/internal/reading/ready.inpage.ts +164 -0
  134. package/src/internal/reading/ready.ts +111 -0
  135. package/src/internal/reading/subjects.inpage.ts +188 -0
  136. package/src/internal/reading/text.inpage.ts +135 -0
  137. package/src/internal/reading/walk.inpage.ts +220 -0
  138. package/src/internal/supervisor/lifecycle.ts +416 -0
  139. package/src/internal/timeline/changes.inpage.ts +216 -0
  140. package/src/internal/timeline/changes.ts +100 -0
  141. package/src/internal/timeline/events.ts +94 -0
  142. package/src/internal/timeline/fold.inpage.ts +123 -0
  143. package/src/internal/timeline/history.inpage.ts +229 -0
  144. package/src/internal/timeline/marks.inpage.ts +207 -0
  145. package/src/internal/timeline/record.inpage.ts +133 -0
  146. package/src/internal/timeline/sight.inpage.ts +122 -0
  147. package/src/internal/timeline/window.ts +96 -0
  148. package/src/internal/timeline/words.ts +65 -0
  149. package/dist/Bootstrap-B9UUXsvF.d.mts +0 -183
  150. package/dist/Bootstrap-CeQOWBy6.mjs +0 -189
  151. package/dist/Bootstrap-CeQOWBy6.mjs.map +0 -1
  152. package/dist/Bootstrap.d.mts +0 -3
  153. package/dist/Bootstrap.mjs +0 -2
  154. package/dist/Browser-BF9_sORP.d.mts +0 -388
  155. package/dist/Browser.mjs.map +0 -1
  156. package/dist/BrowserData-CPIIIHol.d.mts +0 -585
  157. package/dist/BrowserData.d.mts +0 -3
  158. package/dist/BrowserData.mjs +0 -695
  159. package/dist/BrowserData.mjs.map +0 -1
  160. package/dist/BrowserRuntime-B6nT1G4X.d.mts +0 -148
  161. package/dist/BrowserRuntime-C1r2aSRK.mjs +0 -6820
  162. package/dist/BrowserRuntime-C1r2aSRK.mjs.map +0 -1
  163. package/dist/BrowserRuntime.d.mts +0 -3
  164. package/dist/BrowserRuntime.mjs +0 -3
  165. package/dist/Capture.d.mts +0 -56
  166. package/dist/Capture.mjs +0 -678
  167. package/dist/Capture.mjs.map +0 -1
  168. package/dist/CaptureData-CYriZHX1.mjs +0 -201
  169. package/dist/CaptureData-CYriZHX1.mjs.map +0 -1
  170. package/dist/CaptureData.d.mts +0 -219
  171. package/dist/CaptureData.mjs +0 -2
  172. package/dist/Errors.d.mts +0 -158
  173. package/dist/Errors.mjs +0 -207
  174. package/dist/Errors.mjs.map +0 -1
  175. package/dist/Images-yCG9BmRU.mjs +0 -76
  176. package/dist/Images-yCG9BmRU.mjs.map +0 -1
  177. package/dist/OperationOptions-DAeRS6O9.mjs +0 -55
  178. package/dist/OperationOptions-DAeRS6O9.mjs.map +0 -1
  179. package/dist/PageControl.d.mts +0 -15
  180. package/dist/PageControl.mjs +0 -34
  181. package/dist/PageControl.mjs.map +0 -1
  182. package/dist/PageRegistry-B3q9DS3g.mjs +0 -164
  183. package/dist/PageRegistry-B3q9DS3g.mjs.map +0 -1
  184. package/dist/Plan-Dyxu2q8C.mjs +0 -543
  185. package/dist/Plan-Dyxu2q8C.mjs.map +0 -1
  186. package/dist/PlanData-B668RxbQ.d.mts +0 -4831
  187. package/dist/PlanData.d.mts +0 -3
  188. package/dist/PlanData.mjs +0 -268
  189. package/dist/PlanData.mjs.map +0 -1
  190. package/dist/PlanInput-BJcPPncs.mjs +0 -75
  191. package/dist/PlanInput-BJcPPncs.mjs.map +0 -1
  192. package/dist/PublicSession-DCSMMqOj.mjs +0 -5602
  193. package/dist/PublicSession-DCSMMqOj.mjs.map +0 -1
  194. package/dist/Testing.d.mts +0 -479
  195. package/dist/Testing.mjs +0 -2147
  196. package/dist/Testing.mjs.map +0 -1
  197. package/dist/Timeline-fV22Xk-S.d.mts +0 -29
  198. package/dist/Timeline.d.mts +0 -4
  199. package/dist/Timeline.mjs +0 -20
  200. package/dist/Timeline.mjs.map +0 -1
  201. package/dist/TimelineData-BsOn-YGZ.d.mts +0 -3818
  202. package/dist/TimelineData.d.mts +0 -3
  203. package/dist/TimelineData.mjs +0 -482
  204. package/dist/TimelineData.mjs.map +0 -1
package/src/Stage.ts ADDED
@@ -0,0 +1,467 @@
1
+ /**
2
+ * An output's capture ownership. Cue a page while preparing an event; commit when that event
3
+ * happens. A prepared cue belongs to its caller's scope, and a committed one to the stage.
4
+ * Commits serialize, drain old-page input and refuse stale first frames. `shown` distinguishes
5
+ * confirmed silence, uncertain silence and loss without treating capture silence as evidence.
6
+ * @since 0.3.0
7
+ */
8
+ import {
9
+ Cause,
10
+ Clock,
11
+ Deferred,
12
+ Duration,
13
+ Effect,
14
+ Exit,
15
+ Fiber,
16
+ Option,
17
+ PubSub,
18
+ Result,
19
+ Schedule,
20
+ Schema,
21
+ Scope,
22
+ Stream,
23
+ } from "effect";
24
+
25
+ import { type BrowserError, Closed, consequence, InvalidRequest, Timeout } from "./BrowserError.ts";
26
+ import type { CaptureStats, Frame, ScreencastOptions } from "./Frame.ts";
27
+ import { failWith, undispatched } from "./internal/page/context.ts";
28
+ import { internalsOf } from "./internal/page/page.ts";
29
+ import type { Page } from "./Page.ts";
30
+
31
+ export interface Options extends ScreencastOptions {
32
+ /** Bound for each first-frame wait. Defaults to 5 seconds. */
33
+ readonly firstFrame?: Duration.Input | undefined;
34
+ }
35
+
36
+ /** A switch that took effect, on the clock shared by browser events and frames. */
37
+ export class Presented extends Schema.Class<Presented>("effect-browser/Presented")({
38
+ page: Schema.String,
39
+ at: Schema.Finite,
40
+ latency: Schema.Finite,
41
+ }) {}
42
+
43
+ export interface Cue {
44
+ readonly page: Page;
45
+ /** A frame of the current document, or the capture's failure or first-frame timeout. */
46
+ readonly ready: Effect.Effect<void, BrowserError>;
47
+ /** Commit on the event's host time, or now. Interruption before switching leaves the cue usable. */
48
+ readonly commit: (options?: {
49
+ readonly at?: number | undefined;
50
+ }) => Effect.Effect<Presented, BrowserError>;
51
+ }
52
+
53
+ /** What the output establishes about its last image; loss ends this page's evidence. */
54
+ export type Shown =
55
+ | { readonly _tag: "Painted"; readonly frame: Frame }
56
+ | { readonly _tag: "Still"; readonly frame: Frame; readonly at: number }
57
+ | { readonly _tag: "Stalled"; readonly frame: Frame; readonly at: number }
58
+ | { readonly _tag: "Gone"; readonly page: string; readonly at: number };
59
+
60
+ export interface Stage {
61
+ readonly present: (
62
+ page: Page,
63
+ options?: { readonly at?: number | undefined },
64
+ ) => Effect.Effect<Presented, BrowserError>;
65
+ readonly cue: (page: Page) => Effect.Effect<Cue, BrowserError, Scope.Scope>;
66
+ /** Paint and, after every finite positive hold without paint, current evidence or loss. */
67
+ readonly shown: (options: { readonly hold: Duration.Input }) => Stream.Stream<Shown>;
68
+ /** Current-page paint; a reader more than 16 frames behind loses the oldest. */
69
+ readonly frames: Stream.Stream<Frame>;
70
+ readonly current: Effect.Effect<Option.Option<Page>>;
71
+ readonly stats: (options?: {
72
+ readonly window?: Duration.Input | undefined;
73
+ }) => Effect.Effect<Option.Option<CaptureStats>, BrowserError>;
74
+ }
75
+
76
+ type FeedState =
77
+ | { readonly _tag: "Warming" }
78
+ | { readonly _tag: "Ready"; readonly latest: Frame | undefined }
79
+ | { readonly _tag: "Stopped" };
80
+ interface Feed {
81
+ readonly page: Page;
82
+ readonly scope: Scope.Closeable;
83
+ readonly changed: PubSub.PubSub<Frame>;
84
+ state: FeedState;
85
+ fiber: Fiber.Fiber<void, BrowserError> | undefined;
86
+ }
87
+ interface Paint {
88
+ readonly feed: Feed;
89
+ readonly frame: Frame;
90
+ }
91
+
92
+ const closed = () => undispatched("cue", new Closed({ cause: "released" }));
93
+
94
+ /** The sole writer of a cue's capture lifecycle; presentation ownership belongs to the stage. */
95
+ const update = (
96
+ feed: Feed,
97
+ input: { readonly _tag: "Paint"; readonly frame: Frame } | { readonly _tag: "Reset" | "Stop" },
98
+ ) => {
99
+ if (feed.state._tag === "Stopped") return;
100
+ if (input._tag === "Stop") feed.state = { _tag: "Stopped" };
101
+ else if (input._tag === "Paint") feed.state = { _tag: "Ready", latest: input.frame };
102
+ else if (feed.state._tag === "Ready") feed.state = { _tag: "Ready", latest: undefined };
103
+ };
104
+
105
+ const restartable = (error: BrowserError) => {
106
+ const { lost, repeat } = consequence(error);
107
+
108
+ return lost === "nothing" && repeat !== "pointless";
109
+ };
110
+
111
+ const stop = (feed: Feed) =>
112
+ Effect.suspend(() => {
113
+ update(feed, { _tag: "Stop" });
114
+
115
+ return Scope.close(feed.scope, Exit.void);
116
+ });
117
+
118
+ const frameOf = (feed: Feed) => {
119
+ const frame = feed.state._tag === "Ready" ? feed.state.latest : undefined;
120
+ const internals = internalsOf(feed.page);
121
+
122
+ return frame !== undefined && (internals === undefined || internals.current(frame))
123
+ ? frame
124
+ : undefined;
125
+ };
126
+
127
+ const ended = (feed: Feed): Effect.Effect<never, BrowserError> =>
128
+ feed.fiber === undefined
129
+ ? Effect.never
130
+ : Fiber.await(feed.fiber).pipe(
131
+ Effect.flatMap((exit) =>
132
+ Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)
133
+ ? Effect.failCause(exit.cause)
134
+ : Effect.fail(closed()),
135
+ ),
136
+ );
137
+
138
+ const firstOf = (feed: Feed, wait: Duration.Duration) =>
139
+ Effect.suspend(() => {
140
+ if (feed.state._tag === "Stopped") return Effect.fail(closed());
141
+ if (feed.page.playwright.isClosed()) return failWith("cue", new Closed({ cause: "page" }));
142
+ if (frameOf(feed) !== undefined) return Effect.void;
143
+
144
+ return Effect.raceFirst(
145
+ Stream.fromPubSub(feed.changed).pipe(
146
+ Stream.filter(() => frameOf(feed) !== undefined),
147
+ Stream.runHead,
148
+ Effect.asVoid,
149
+ ),
150
+ ended(feed),
151
+ ).pipe(
152
+ Effect.timeoutOrElse({
153
+ duration: wait,
154
+ orElse: () => failWith("cue", new Timeout({ millis: Duration.toMillis(wait) })),
155
+ }),
156
+ );
157
+ });
158
+
159
+ // A timer can wake a fraction early; a commit never precedes its event.
160
+ const until = (at: number, now: () => number): Effect.Effect<void> =>
161
+ Effect.suspend(() =>
162
+ at > now()
163
+ ? Effect.sleep(Duration.millis(Math.ceil(at - now()))).pipe(Effect.andThen(until(at, now)))
164
+ : Effect.void,
165
+ );
166
+
167
+ /** Reserve the next turn before waking it; a fresh caller cannot overtake a queued commit. */
168
+ const ordered = () => {
169
+ const waiting: Array<Deferred.Deferred<void>> = [];
170
+
171
+ return <A, E, R>(effect: Effect.Effect<A, E, R>) =>
172
+ Effect.acquireUseRelease(
173
+ Effect.sync(() => {
174
+ const turn = Deferred.makeUnsafe<void>();
175
+
176
+ waiting.push(turn);
177
+ if (waiting.length === 1) Deferred.doneUnsafe(turn, Exit.void);
178
+
179
+ return turn;
180
+ }),
181
+ (turn) => Deferred.await(turn).pipe(Effect.andThen(effect)),
182
+ (turn) =>
183
+ Effect.sync(() => {
184
+ const at = waiting.indexOf(turn);
185
+
186
+ waiting.splice(at, 1);
187
+ if (at === 0 && waiting[0] !== undefined) Deferred.doneUnsafe(waiting[0], Exit.void);
188
+ }),
189
+ );
190
+ };
191
+
192
+ /** A terminal capture can know the connection is gone before the page receives its close event. */
193
+ const confirm = (feed: Feed, frame: Frame) => {
194
+ const exit = feed.fiber?.pollUnsafe();
195
+
196
+ const failure =
197
+ exit !== undefined && Exit.isFailure(exit) ? Cause.findErrorOption(exit.cause) : Option.none();
198
+
199
+ if (Option.isSome(failure) && consequence(failure.value).lost !== "nothing")
200
+ return Effect.fail(failure.value);
201
+
202
+ return feed.page.playwright.isClosed()
203
+ ? failWith("shown", new Closed({ cause: "page" }))
204
+ : (internalsOf(feed.page)?.confirm(frame) ?? Effect.succeed(false));
205
+ };
206
+
207
+ /** Each subscriber confirms silence; a new frame or commit invalidates a probe. */
208
+ const evidence = (
209
+ output: PubSub.PubSub<Paint>,
210
+ current: () =>
211
+ | { readonly feed: Feed; readonly frame: Frame; readonly changedAt: number }
212
+ | undefined,
213
+ now: () => number,
214
+ hold: Duration.Input,
215
+ ): Stream.Stream<Shown> =>
216
+ Stream.unwrap(
217
+ Effect.sync(() => {
218
+ const parsed = Duration.fromInput(hold);
219
+
220
+ if (
221
+ Option.isNone(parsed) ||
222
+ !Duration.isFinite(parsed.value) ||
223
+ !Duration.isPositive(parsed.value)
224
+ )
225
+ throw new RangeError("shown hold must be a finite positive duration");
226
+ const millis = Duration.toMillis(parsed.value);
227
+ let checkedAt = now();
228
+ let gone: Feed | undefined;
229
+
230
+ const probe = Effect.gen(function* () {
231
+ yield* until(Math.max(checkedAt, current()?.changedAt ?? checkedAt) + millis, now);
232
+ checkedAt = now();
233
+ const before = current();
234
+ const frame = before?.frame;
235
+
236
+ if (
237
+ before === undefined ||
238
+ frame === undefined ||
239
+ gone === before.feed ||
240
+ checkedAt - before.changedAt < millis
241
+ )
242
+ return Option.none<Shown>();
243
+ const { feed } = before;
244
+
245
+ const confirmed = yield* Effect.result(confirm(feed, frame));
246
+
247
+ if (current()?.feed !== feed || current()?.frame !== frame) return Option.none<Shown>();
248
+ const at = now();
249
+
250
+ checkedAt = at;
251
+ if (Result.isFailure(confirmed) && consequence(confirmed.failure).lost !== "nothing") {
252
+ gone = feed;
253
+
254
+ return Option.some<Shown>({ _tag: "Gone", page: feed.page.id, at });
255
+ }
256
+
257
+ return Option.some<Shown>({
258
+ _tag: Result.isSuccess(confirmed) && confirmed.success ? "Still" : "Stalled",
259
+ frame,
260
+ at,
261
+ });
262
+ });
263
+
264
+ return Stream.merge(
265
+ Stream.fromPubSub(output).pipe(
266
+ Stream.filter(({ feed }) => gone !== feed),
267
+ Stream.map(({ frame }): Shown => ({ _tag: "Painted", frame })),
268
+ ),
269
+ Stream.fromEffectRepeat(probe).pipe(
270
+ Stream.filter(Option.isSome),
271
+ Stream.map(({ value }) => value),
272
+ ),
273
+ );
274
+ }),
275
+ );
276
+
277
+ const openFeed = (
278
+ page: Page,
279
+ owner: Scope.Scope,
280
+ clock: Clock.Clock,
281
+ settings: ScreencastOptions,
282
+ current: () => Feed | undefined,
283
+ publish: (feed: Feed, frame: Frame) => Effect.Effect<unknown>,
284
+ ) =>
285
+ Effect.gen(function* () {
286
+ const scope = yield* Scope.fork(owner);
287
+ const started = yield* Deferred.make<void>();
288
+
289
+ const feed: Feed = {
290
+ page,
291
+ scope,
292
+ changed: yield* PubSub.sliding<Frame>({ capacity: 1, replay: 1 }),
293
+ state: { _tag: "Warming" },
294
+ fiber: undefined,
295
+ };
296
+
297
+ yield* Scope.addFinalizer(
298
+ scope,
299
+ Effect.sync(() => {
300
+ update(feed, { _tag: "Stop" });
301
+ }),
302
+ );
303
+ yield* Effect.addFinalizer(() => (current() === feed ? Effect.void : stop(feed)));
304
+
305
+ const accept = (frame: Frame) =>
306
+ Effect.suspend(() => {
307
+ update(feed, { _tag: "Paint", frame });
308
+
309
+ return (current() === feed ? publish(feed, frame) : Effect.void).pipe(
310
+ Effect.andThen(PubSub.publish(feed.changed, frame)),
311
+ );
312
+ });
313
+
314
+ const capture = Effect.scoped(
315
+ Effect.gen(function* () {
316
+ update(feed, { _tag: "Reset" });
317
+
318
+ const internals = internalsOf(page);
319
+
320
+ const { stream, current: frame } = yield* (
321
+ internals?.capture(settings) ??
322
+ Effect.succeed({ stream: page.screencast(settings), current: undefined })
323
+ );
324
+
325
+ if (frame !== undefined && internals?.current(frame)) yield* accept(frame);
326
+ yield* Deferred.succeed(started, undefined);
327
+ yield* Stream.runForEach(stream, accept);
328
+ }),
329
+ ).pipe(
330
+ Effect.ensuring(Effect.sync(() => update(feed, { _tag: "Reset" }))),
331
+ Effect.retry({
332
+ while: (error) => feed.state._tag === "Ready" && restartable(error),
333
+ schedule: Schedule.spaced("500 millis"),
334
+ }),
335
+ );
336
+
337
+ feed.fiber = yield* capture.pipe(
338
+ Effect.provideService(Clock.Clock, clock),
339
+ Effect.forkIn(scope),
340
+ );
341
+ yield* Effect.raceFirst(Deferred.await(started), ended(feed)).pipe(
342
+ Effect.onError(() => stop(feed)),
343
+ );
344
+
345
+ return feed;
346
+ });
347
+
348
+ export const make = Effect.fn("Stage.make")(function* (
349
+ options: Options = {},
350
+ ): Effect.fn.Return<Stage, BrowserError, Scope.Scope> {
351
+ const clock = yield* Clock.Clock;
352
+ const owner = yield* Effect.scope;
353
+ const now = () => Number(clock.monotonicTimeNanosUnsafe()) / 1e6;
354
+ const waited = Duration.fromInput(options.firstFrame ?? Duration.seconds(5));
355
+
356
+ if (
357
+ Option.isNone(waited) ||
358
+ !Duration.isFinite(waited.value) ||
359
+ !Duration.isPositive(waited.value)
360
+ )
361
+ return yield* failWith(
362
+ "stage",
363
+ new InvalidRequest({ detail: "firstFrame must be a finite positive duration" }),
364
+ );
365
+ const firstFrame = waited.value;
366
+ const settings = { quality: options.quality, size: options.size };
367
+ const output = yield* PubSub.sliding<Paint>(16);
368
+ const switching = ordered();
369
+
370
+ let showing:
371
+ | { readonly feed: Feed; readonly presented: Presented; frame: Frame; changedAt: number }
372
+ | undefined;
373
+
374
+ let painted = Number.NEGATIVE_INFINITY;
375
+
376
+ yield* Effect.addFinalizer(() => PubSub.shutdown(output));
377
+
378
+ const publish = (feed: Feed, frame: Frame) =>
379
+ Effect.suspend(() => {
380
+ painted = Math.max(painted, frame.hostTime);
381
+ if (showing !== undefined) {
382
+ showing.changedAt = now();
383
+ showing.frame = frame;
384
+ }
385
+
386
+ return PubSub.publish(output, { feed, frame });
387
+ });
388
+
389
+ const commit = (feed: Feed, options: { readonly at?: number | undefined } = {}) =>
390
+ Effect.suspend(() => {
391
+ const asked = options.at ?? now();
392
+
393
+ return switching(
394
+ Effect.gen(function* () {
395
+ if (!Number.isFinite(asked))
396
+ return yield* failWith(
397
+ "cue",
398
+ new InvalidRequest({ detail: "at must be finite host milliseconds" }),
399
+ );
400
+ yield* firstOf(feed, firstFrame);
401
+ const old = showing;
402
+
403
+ if (old?.feed === feed) return old.presented;
404
+
405
+ yield* Effect.raceFirst(until(asked, now), ended(feed));
406
+
407
+ const switchTo = Effect.suspend(() => {
408
+ const frame = frameOf(feed);
409
+
410
+ if (frame === undefined) return Effect.fail(closed());
411
+ const at = Math.max(now(), asked, frame.hostTime, painted);
412
+
413
+ showing = {
414
+ feed,
415
+ frame,
416
+ presented: new Presented({ page: feed.page.id, at, latency: at - asked }),
417
+ changedAt: at,
418
+ };
419
+
420
+ return publish(feed, frame).pipe(
421
+ Effect.andThen(old === undefined ? Effect.void : stop(old.feed)),
422
+ Effect.as(showing.presented),
423
+ );
424
+ }).pipe(Effect.uninterruptible);
425
+
426
+ const readyToSwitch = firstOf(feed, firstFrame).pipe(
427
+ Effect.andThen(switchTo),
428
+ Effect.provideService(Clock.Clock, clock),
429
+ );
430
+
431
+ const before = old === undefined ? undefined : internalsOf(old.feed.page);
432
+
433
+ return yield* before === undefined ? readyToSwitch : before.afterInput(readyToSwitch);
434
+ }).pipe(Effect.provideService(Clock.Clock, clock)),
435
+ );
436
+ });
437
+
438
+ const cue: Stage["cue"] = (page) =>
439
+ Effect.map(
440
+ openFeed(page, owner, clock, settings, () => showing?.feed, publish),
441
+ (feed) => ({
442
+ page,
443
+ ready: firstOf(feed, firstFrame).pipe(Effect.provideService(Clock.Clock, clock)),
444
+ commit: (options) => commit(feed, options),
445
+ }),
446
+ );
447
+
448
+ return {
449
+ cue,
450
+ present: (page, options) =>
451
+ Effect.suspend(() => {
452
+ const asked = options?.at ?? now();
453
+
454
+ return showing?.feed.page === page
455
+ ? Effect.as(firstOf(showing.feed, firstFrame), showing.presented)
456
+ : Effect.scoped(Effect.flatMap(cue(page), (prepared) => prepared.commit({ at: asked })));
457
+ }),
458
+ shown: ({ hold }) =>
459
+ evidence(output, () => showing, now, hold).pipe(Stream.provideService(Clock.Clock, clock)),
460
+ frames: Stream.fromPubSub(output).pipe(Stream.map(({ frame }) => frame)),
461
+ current: Effect.sync(() => Option.fromNullishOr(showing?.feed.page)),
462
+ stats: (options = {}) =>
463
+ showing === undefined
464
+ ? Effect.succeedNone
465
+ : Effect.asSome(showing.feed.page.captureStats({ window: options.window ?? "1 second" })),
466
+ } satisfies Stage;
467
+ });