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,778 @@
1
+ /**
2
+ * One CDP screencast per page, with bounded transport replies and per-reader loss accounting.
3
+ * Native callbacks own synchronous bookkeeping; the page scope owns startup and teardown.
4
+ */
5
+ import { Clock, Deferred, Duration, Effect, Exit, Option, Queue, Semaphore, Stream } from "effect";
6
+
7
+ import { type BrowserError, InvalidRequest } from "../../BrowserError.ts";
8
+ import { BrowserPaint, CaptureStats, Frame, type ScreencastOptions } from "../../Frame.ts";
9
+ import { undispatched } from "../page/context.ts";
10
+ import { type Estimate, toHostTime, uncertaintyAt } from "./clock.ts";
11
+ import type { NativeFrame, Transport } from "./transport.ts";
12
+
13
+ export interface Size {
14
+ readonly width: number;
15
+ readonly height: number;
16
+ }
17
+
18
+ interface Options {
19
+ readonly id: string;
20
+ /** Where each capture's screencast runs, found as it starts. */
21
+ readonly transport: Effect.Effect<Transport, BrowserError>;
22
+ readonly clock: Clock.Clock;
23
+ /** The estimate a capture starts with, once the page can be captured. */
24
+ readonly calibrate: Effect.Effect<Estimate, BrowserError>;
25
+ /** The browser's newest estimate, which times each frame. */
26
+ readonly latest: () => Estimate | undefined;
27
+ /** Measure the clock again if it is due, while frames flow. */
28
+ readonly renew: Effect.Effect<void>;
29
+ /** What the page needs from its own session while frames flow, sent as a capture starts. */
30
+ readonly watch: Effect.Effect<unknown, BrowserError>;
31
+ /** How long frames are kept, measured back from the newest. */
32
+ readonly frameHistory: Duration.Duration;
33
+ /** The viewport in CSS pixels; a capture is scaled to fit it, as screenshots are. */
34
+ readonly viewport: Effect.Effect<Size, BrowserError>;
35
+ readonly imageSize: (data: Uint8Array) => Size | undefined;
36
+ readonly error: (cause: unknown) => BrowserError;
37
+ }
38
+
39
+ type Envelope =
40
+ | { readonly _tag: "Frame"; readonly sequence: number; readonly frame: Frame }
41
+ | { readonly _tag: "Failure"; readonly error: BrowserError };
42
+
43
+ interface Held {
44
+ readonly frame: Frame;
45
+ readonly timestamp: number;
46
+ }
47
+
48
+ /** Consecutive frames of one device size other than the expected one, oldest first. */
49
+ interface Run {
50
+ readonly device: Size;
51
+ /** When its first frame arrived, on the owner's monotonic clock. */
52
+ readonly since: number;
53
+ readonly frames: Array<Held>;
54
+ }
55
+
56
+ interface Generation {
57
+ readonly subscribers: Set<Queue.Queue<Envelope>>;
58
+ readonly transport: Transport;
59
+ /** Stops handing the transport's frames to this generation. */
60
+ unlisten: () => void;
61
+ readonly calibration: Estimate;
62
+ /** The native capture's settings; every reader of this generation shares them. */
63
+ readonly quality: number;
64
+ readonly size: Size | null;
65
+ readonly onFrame: (frame: NativeFrame) => void;
66
+ accepting: boolean;
67
+ predecessor: number | undefined;
68
+ failure: BrowserError | undefined;
69
+ stopReply: Promise<void> | undefined;
70
+ }
71
+
72
+ export interface Controller {
73
+ readonly stream: (options?: ScreencastOptions) => Stream.Stream<Frame, BrowserError>;
74
+ readonly latest: Effect.Effect<Option.Option<Frame>>;
75
+ readonly recent: Effect.Effect<ReadonlyArray<Frame>>;
76
+ /** Lifetime counts, or those of the latest `windowMillis`, at most `countsKept`. */
77
+ readonly stats: (windowMillis?: number) => Effect.Effect<CaptureStats>;
78
+ readonly active: Effect.Effect<boolean>;
79
+ /** Once every frame the running capture's connection sent before now has arrived. */
80
+ readonly sync: Effect.Effect<void, BrowserError>;
81
+ /** Run a clipped or scaled picture of the page, keeping what it draws out of the capture. */
82
+ readonly excluding: <A, E, R>(
83
+ picture: Effect.Effect<A, E, R>,
84
+ resized?: boolean,
85
+ ) => Effect.Effect<A, E, R>;
86
+ }
87
+
88
+ /** When the library took a clipped picture: from its call to its reply, in host milliseconds. */
89
+ interface Excluded {
90
+ readonly from: number;
91
+ until: number;
92
+ readonly resized: boolean;
93
+ }
94
+
95
+ const replyCapacity = 32;
96
+ const subscriberCapacity = 16;
97
+ const deadline = Duration.seconds(2);
98
+
99
+ // Device sizes can be fractional at some scale factors; the page reports whole pixels.
100
+ const sameSize = (left: Size, right: Size) =>
101
+ Math.abs(left.width - right.width) < 1 && Math.abs(left.height - right.height) < 1;
102
+
103
+ // A frame is its device scaled to fit, so it keeps the device's shape to within a pixel a side.
104
+ const sameShape = (image: Size, device: Size) =>
105
+ Math.abs(image.width * device.height - image.height * device.width) <
106
+ device.width + device.height;
107
+
108
+ // Paint-time gaps between consecutive delivered frames.
109
+ const paintGaps = () => {
110
+ let count = 0;
111
+ let totalMillis = 0;
112
+ let minMillis: number | null = null;
113
+ let maxMillis: number | null = null;
114
+ let lastMillis: number | null = null;
115
+
116
+ return {
117
+ add: (gap: number) => {
118
+ count++;
119
+ totalMillis += gap;
120
+ minMillis = minMillis === null ? gap : Math.min(minMillis, gap);
121
+ maxMillis = maxMillis === null ? gap : Math.max(maxMillis, gap);
122
+ lastMillis = gap;
123
+ },
124
+ snapshot: () => ({ count, totalMillis, minMillis, maxMillis, lastMillis }),
125
+ };
126
+ };
127
+
128
+ const none = () => ({
129
+ received: 0,
130
+ accepted: 0,
131
+ late: 0,
132
+ missingTimestamp: 0,
133
+ foreignSize: 0,
134
+ duringPictures: 0,
135
+ lost: 0,
136
+ });
137
+
138
+ type Counted = keyof ReturnType<typeof none>;
139
+
140
+ /** How far back a window of capture counts can reach. */
141
+ export const countsKept = Duration.minutes(1);
142
+
143
+ /**
144
+ * A capture's counts and paint gaps over its page's life, and each with when it happened on the
145
+ * owner's clock for the latest minute, so a window of them can be read.
146
+ */
147
+ const tally = (now: () => number) => {
148
+ const totals = none();
149
+ const gaps = paintGaps();
150
+
151
+ let recent: Array<{
152
+ readonly at: number;
153
+ readonly counted: Counted | "gap";
154
+ readonly n: number;
155
+ }> = [];
156
+
157
+ let oldest = 0;
158
+
159
+ const note = (counted: Counted | "gap", n: number) => {
160
+ const at = now();
161
+
162
+ recent.push({ at, counted, n });
163
+ while ((recent[oldest]?.at ?? at) < at - Duration.toMillis(countsKept)) oldest++;
164
+ // Shed what is out of reach once it is half the log.
165
+ if (oldest > recent.length / 2) {
166
+ recent = recent.slice(oldest);
167
+ oldest = 0;
168
+ }
169
+ };
170
+
171
+ return {
172
+ count: (counted: Counted, n = 1) => {
173
+ totals[counted] += n;
174
+ note(counted, n);
175
+ },
176
+ gap: (millis: number) => {
177
+ gaps.add(millis);
178
+ note("gap", millis);
179
+ },
180
+ /** Lifetime counts, or those of the latest `windowMillis`, at most a minute. */
181
+ read: (windowMillis?: number) => {
182
+ if (windowMillis === undefined) return { ...totals, gaps: gaps.snapshot() };
183
+ const counts = none();
184
+ const windowed = paintGaps();
185
+ const since = now() - windowMillis;
186
+
187
+ for (const { at, counted, n } of recent.slice(oldest)) {
188
+ if (at < since) continue;
189
+ if (counted === "gap") windowed.add(n);
190
+ else counts[counted] += n;
191
+ }
192
+
193
+ return { ...counts, gaps: windowed.snapshot() };
194
+ },
195
+ };
196
+ };
197
+
198
+ // Another capture's frames last only as long as it does; a run of frames that lasts longer is
199
+ // the page's own.
200
+ const outlasting = Duration.seconds(1);
201
+
202
+ // No frame painted during a picture arrives this long after it.
203
+ const excludedFor = Duration.seconds(10);
204
+
205
+ // Every frame of a crop is painted before Chromium answers the picture, yet each test of it can
206
+ // land past the reply: its stamp, because Chromium stamps frames on another clock than the one the
207
+ // calibration reads (2 to 3 ms late on an idle machine here), and its arrival, because a frame is
208
+ // encoded after it is painted (3 to 6 ms). A frame is kept only when both land this long after.
209
+ const settling = Duration.millis(50);
210
+
211
+ /**
212
+ * Chromium draws the library's own clipped pictures into the running screencast. Where Playwright
213
+ * emulates the viewport they keep the page's device size, and its shape when the crop has the
214
+ * viewport's proportions, so nothing in a frame tells them apart: a frame painted, or arriving,
215
+ * while one is taken is left out, the page's own included. A crop on the page's own session is
216
+ * drawn at the crop's size, so there only a frame of another size than the page's is left out.
217
+ */
218
+ const pictureWindows = (now: () => number) => {
219
+ let windows: ReadonlyArray<Excluded> = [];
220
+
221
+ return {
222
+ drawn: ({ hostTime, timing, receivedAt }: Frame, foreign: boolean) =>
223
+ windows.some((window) => {
224
+ const until = window.until + Duration.toMillis(settling);
225
+
226
+ return (
227
+ (foreign || !window.resized) &&
228
+ ((hostTime + timing.uncertaintyMillis >= window.from &&
229
+ hostTime - timing.uncertaintyMillis <= until) ||
230
+ (receivedAt >= window.from && receivedAt <= until))
231
+ );
232
+ }),
233
+ excluding: <A, E, R>(picture: Effect.Effect<A, E, R>, resized = false) =>
234
+ Effect.acquireUseRelease(
235
+ Effect.sync(() => {
236
+ const window = { from: now(), until: Number.POSITIVE_INFINITY, resized };
237
+ const since = window.from - Duration.toMillis(excludedFor);
238
+
239
+ windows = [...windows.filter((kept) => kept.until > since), window];
240
+
241
+ return window;
242
+ }),
243
+ () => picture,
244
+ (window) =>
245
+ Effect.sync(() => {
246
+ window.until = now();
247
+ }),
248
+ ),
249
+ };
250
+ };
251
+
252
+ /**
253
+ * A native frame as readers get it: its picture's size, else the capture's or its device's, and
254
+ * its paint mapped by the browser's newest estimate, which may be older than the frame and says
255
+ * less about later paint.
256
+ */
257
+ const timed = (
258
+ options: Options,
259
+ native: NativeFrame,
260
+ timestamp: number,
261
+ generation: Pick<Generation, "calibration" | "size" | "transport">,
262
+ ) => {
263
+ const data = Buffer.from(native.data, "base64");
264
+ const image = options.imageSize(data);
265
+ const device = { width: native.metadata.deviceWidth, height: native.metadata.deviceHeight };
266
+ const size = image ?? generation.size ?? device;
267
+ const estimate = options.latest() ?? generation.calibration;
268
+ const hostTime = toHostTime(estimate, timestamp);
269
+
270
+ const frame = new Frame({
271
+ page: options.id,
272
+ data,
273
+ timing: new BrowserPaint({
274
+ timestamp,
275
+ hostTime,
276
+ uncertaintyMillis: uncertaintyAt(estimate, hostTime),
277
+ }),
278
+ receivedAt: Number(options.clock.monotonicTimeNanosUnsafe()) / 1e6,
279
+ width: size.width,
280
+ height: size.height,
281
+ ...generation.transport.frameTag(),
282
+ });
283
+
284
+ return { frame, image, device };
285
+ };
286
+
287
+ // A page too busy to report its viewport in time reads as null.
288
+ const readViewport = (options: Options) =>
289
+ options.viewport.pipe(
290
+ Effect.interruptible,
291
+ Effect.timeoutOrElse({ duration: deadline, orElse: () => Effect.succeed(null) }),
292
+ Effect.orElseSucceed(() => null),
293
+ Effect.provideService(Clock.Clock, options.clock),
294
+ );
295
+
296
+ /**
297
+ * What a new capture starts with: where it runs, the clock's estimate, and the viewport unless
298
+ * the reader gave a size. A capture connection attaches while the page's own session maps the
299
+ * clock, and a page too busy to report its viewport is still captured, at device size.
300
+ */
301
+ const prepare = (options: Options, screencast: ScreencastOptions) =>
302
+ Effect.all(
303
+ [
304
+ options.transport.pipe(Effect.interruptible),
305
+ Effect.gen(function* () {
306
+ const calibration = yield* options.calibrate.pipe(Effect.interruptible);
307
+ const viewport = screencast.size === undefined ? yield* readViewport(options) : null;
308
+
309
+ return { calibration, viewport };
310
+ }),
311
+ ],
312
+ { concurrency: 2 },
313
+ );
314
+
315
+ /**
316
+ * Keeps other captures' frames from readers. A clipped or scaled screenshot of the page, taken
317
+ * from any session, draws frames into its running screencast without resizing the page, until
318
+ * Chromium returns to the page's size. Either their picture has another shape than their device,
319
+ * and they are dropped, or their device has another size than the page's frames.
320
+ *
321
+ * A frame of the expected device size goes to readers at once and drops any run of frames of
322
+ * another size before it, as another capture's. A run waits, in order, until the page reports a
323
+ * viewport of its size or until it outlasts any capture: then it was a real resize, so it goes to
324
+ * readers and its size is expected. The page's report only shortens the wait, so a page too busy
325
+ * to answer, or one measuring in other units as under browser zoom, never stalls the stream.
326
+ */
327
+ const sizeFilter = (
328
+ options: Options,
329
+ deliver: (frame: Frame, timestamp: number) => void,
330
+ drop: (frames: number) => void,
331
+ ) =>
332
+ Effect.gen(function* () {
333
+ // Until a capture reads its viewport or delivers a frame, its first frame stands in.
334
+ let expected: Size | null = null;
335
+ let run: Run | undefined;
336
+ // Wakes the decider for a new run or an ended one; wakes while it is busy coalesce.
337
+ const wake = yield* Queue.sliding<void>(1);
338
+
339
+ const end = () => {
340
+ if (run === undefined) return;
341
+ drop(run.frames.length);
342
+ run = undefined;
343
+ Queue.offerUnsafe(wake, undefined);
344
+ };
345
+
346
+ const now = () => Number(options.clock.monotonicTimeNanosUnsafe()) / 1e6;
347
+
348
+ const adopt = (adopted: Run) => {
349
+ run = undefined;
350
+ expected = adopted.device;
351
+ for (const held of adopted.frames) deliver(held.frame, held.timestamp);
352
+ };
353
+
354
+ const decide: Effect.Effect<void> = Effect.suspend(() => {
355
+ const deciding = run;
356
+
357
+ if (deciding === undefined) return Effect.void;
358
+
359
+ return readViewport(options).pipe(
360
+ Effect.flatMap((viewport) => {
361
+ if (run !== deciding) return decide;
362
+ if (viewport !== null && sameSize(viewport, deciding.device))
363
+ return Effect.sync(() => adopt(deciding));
364
+
365
+ return outlast(deciding);
366
+ }),
367
+ );
368
+ });
369
+
370
+ // Waits until the run outlasts any capture, unless a newer frame decides it first.
371
+ const outlast = (waiting: Run): Effect.Effect<void> =>
372
+ Effect.suspend(() => {
373
+ if (run !== waiting) return decide;
374
+ const remaining = waiting.since + Duration.toMillis(outlasting) - now();
375
+
376
+ if (remaining <= 0) return Effect.sync(() => adopt(waiting));
377
+
378
+ return Effect.sleep(Duration.millis(remaining)).pipe(
379
+ Effect.provideService(Clock.Clock, options.clock),
380
+ Effect.race(Queue.take(wake)),
381
+ Effect.andThen(outlast(waiting)),
382
+ );
383
+ });
384
+
385
+ yield* Queue.take(wake).pipe(Effect.andThen(decide), Effect.forever, Effect.forkScoped);
386
+
387
+ return {
388
+ /** Drops the frames still waiting; their capture has stopped. */
389
+ end,
390
+ /** A new capture expects the viewport it read, if any. */
391
+ restart: (viewport: Size | null) => {
392
+ end();
393
+ expected = viewport;
394
+ },
395
+ /** Whether a frame of this device size shows the page, as far as the capture knows. */
396
+ expects: (device: Size) => expected !== null && sameSize(device, expected),
397
+ offer: (frame: Frame, timestamp: number, image: Size | undefined, device: Size) => {
398
+ if (image !== undefined && !sameShape(image, device)) {
399
+ drop(1);
400
+
401
+ return;
402
+ }
403
+ expected ??= device;
404
+ if (sameSize(device, expected)) {
405
+ end();
406
+ deliver(frame, timestamp);
407
+
408
+ return;
409
+ }
410
+ if (run !== undefined && !sameSize(run.device, device)) end();
411
+ if (run === undefined) {
412
+ run = { device, since: now(), frames: [] };
413
+ Queue.offerUnsafe(wake, undefined);
414
+ }
415
+ run.frames.push({ frame, timestamp });
416
+ if (run.frames.length > subscriberCapacity) {
417
+ run.frames.shift();
418
+ drop(1);
419
+ }
420
+ },
421
+ };
422
+ });
423
+
424
+ /**
425
+ * A running capture's failure, told once its page's fate is known. A capture can fail before the
426
+ * page's own session hears why: a capture connection hears the browser's end before the connection
427
+ * that drives the page does, and an acknowledgement can be refused before the browser's loss is
428
+ * known. So the failure waits for the page's scope to close, `closed`, and is then the page's own
429
+ * end or its browser's loss, as `gone` reads it; or, if the page still stands after `deadline`,
430
+ * the capture's own.
431
+ */
432
+ const told =
433
+ (closed: Deferred.Deferred<void>, gone: () => BrowserError, clock: Clock.Clock) =>
434
+ (failure: BrowserError) =>
435
+ Deferred.await(closed).pipe(
436
+ Effect.map(gone),
437
+ Effect.timeoutOrElse({ duration: deadline, orElse: () => Effect.succeed(failure) }),
438
+ Effect.provideService(Clock.Clock, clock),
439
+ Effect.flatMap(Effect.fail),
440
+ );
441
+
442
+ /**
443
+ * Once every frame the running capture's transport sent before now has arrived; a capture that
444
+ * has failed, or whose round trip fails, fails as `why` tells it.
445
+ */
446
+ const synced = (
447
+ current: Generation | undefined,
448
+ why: (failure: BrowserError) => Effect.Effect<never, BrowserError>,
449
+ error: (cause: unknown) => BrowserError,
450
+ ): Effect.Effect<void, BrowserError> => {
451
+ if (current === undefined) return Effect.void;
452
+ if (current.failure !== undefined) return why(current.failure);
453
+
454
+ return Effect.tryPromise({ try: () => current.transport.sync(), catch: error }).pipe(
455
+ Effect.catch(why),
456
+ Effect.asVoid,
457
+ );
458
+ };
459
+
460
+ export const make = (options: Options) =>
461
+ Effect.gen(function* () {
462
+ const lock = yield* Semaphore.make(1);
463
+ const replies = new Set<Promise<void>>();
464
+ let generation: Generation | undefined;
465
+ // Done as the page's scope closes, once the page has gone and its browser's loss, if any, is
466
+ // known.
467
+ const closed = yield* Deferred.make<void>();
468
+ // The latest stop whose reply has not settled. A new capture waits for it rather than
469
+ // assuming it was lost; it is never submitted again.
470
+ let unsettledStop: Promise<void> | undefined;
471
+ let latest = Option.none<Frame>();
472
+ let history: ReadonlyArray<Frame> = [];
473
+ const keepMillis = Duration.toMillis(options.frameHistory);
474
+ const now = () => Number(options.clock.monotonicTimeNanosUnsafe()) / 1e6;
475
+ const counts = tally(now);
476
+ // Frames delivered, which number them so that a reader can tell what it missed.
477
+ let delivered = 0;
478
+ const pictures = pictureWindows(now);
479
+
480
+ // Frames that show the page go to the running capture's readers.
481
+ const deliver = (frame: Frame, timestamp: number) => {
482
+ const current = generation;
483
+
484
+ if (current === undefined) return;
485
+ // Held frames are checked again: frames encoded out of order are dropped, never reordered.
486
+ if (current.predecessor !== undefined && timestamp <= current.predecessor)
487
+ return counts.count("late");
488
+ if (current.predecessor !== undefined) counts.gap(timestamp - current.predecessor);
489
+ current.predecessor = timestamp;
490
+ delivered++;
491
+ counts.count("accepted");
492
+ latest = Option.some(frame);
493
+ history = [...history.filter((kept) => kept.hostTime >= frame.hostTime - keepMillis), frame];
494
+ for (const subscriber of current.subscribers)
495
+ Queue.offerUnsafe(subscriber, { _tag: "Frame", sequence: delivered, frame });
496
+ };
497
+
498
+ const sizes = yield* sizeFilter(options, deliver, (frames) =>
499
+ counts.count("foreignSize", frames),
500
+ );
501
+
502
+ // On air a capture runs for hours, and its frames are timed by the browser's newest estimate,
503
+ // so arriving frames renew it: a check at most each second, a measurement once it is due.
504
+ const renewing = yield* Queue.sliding<void>(1);
505
+
506
+ yield* Queue.take(renewing).pipe(
507
+ Effect.andThen(options.renew),
508
+ Effect.andThen(Effect.sleep(Duration.seconds(1))),
509
+ Effect.provideService(Clock.Clock, options.clock),
510
+ Effect.forever,
511
+ Effect.forkScoped,
512
+ );
513
+
514
+ const notifyFailure = (current: Generation, failure: BrowserError) => {
515
+ if (current.failure !== undefined) return;
516
+ current.failure = failure;
517
+ for (const subscriber of current.subscribers)
518
+ Queue.offerUnsafe(subscriber, { _tag: "Failure", error: failure });
519
+ };
520
+
521
+ const stop = (current: Generation) => {
522
+ if (!current.accepting) return;
523
+ current.accepting = false;
524
+ sizes.end();
525
+ current.unlisten();
526
+
527
+ // Attach both handlers in the callback turn. Even a caller that leaves immediately never
528
+ // abandons a rejected native reply, and an uncertain stop is never submitted twice.
529
+ const reply = current.transport.stop().then(
530
+ () => undefined,
531
+ (cause: unknown) => {
532
+ notifyFailure(current, options.error(cause));
533
+ },
534
+ );
535
+
536
+ current.stopReply = reply;
537
+ unsettledStop = reply;
538
+ void reply.then(() => {
539
+ if (unsettledStop === reply) unsettledStop = undefined;
540
+ });
541
+ };
542
+
543
+ const fail = (current: Generation, failure: BrowserError) => {
544
+ notifyFailure(current, failure);
545
+ stop(current);
546
+ };
547
+
548
+ const gone = () => options.error(new Error("Target page has been closed"));
549
+ const stopDeadline = () => options.error(new Error("screencast stop exceeded its deadline"));
550
+ const why = told(closed, gone, options.clock);
551
+
552
+ // A stop's reply, awaited for at most `deadline`; `late` is what a stop past it means.
553
+ const settled = <E>(reply: () => Promise<void> | undefined, late: Effect.Effect<void, E>) =>
554
+ Effect.suspend(() => {
555
+ const pending = reply();
556
+
557
+ return pending === undefined
558
+ ? Effect.void
559
+ : Effect.promise(() => pending).pipe(
560
+ Effect.interruptible,
561
+ Effect.timeoutOrElse({ duration: deadline, orElse: () => late }),
562
+ );
563
+ }).pipe(Effect.provideService(Clock.Clock, options.clock));
564
+
565
+ const awaitStop = (current: Generation) =>
566
+ settled(
567
+ () => current.stopReply,
568
+ Effect.sync(() => notifyFailure(current, stopDeadline())),
569
+ );
570
+
571
+ // A late stop only delays the next capture; once its reply settles, capture can start again.
572
+ const awaitPreviousStop = settled(() => unsettledStop, Effect.fail(stopDeadline()));
573
+
574
+ // A reader leaves; the last one stops its generation.
575
+ const leave = (current: Generation, subscription: Queue.Queue<Envelope>) =>
576
+ Effect.gen(function* () {
577
+ current.subscribers.delete(subscription);
578
+ yield* Queue.shutdown(subscription);
579
+ if (current.subscribers.size !== 0) return;
580
+ yield* Effect.sync(() => stop(current));
581
+ yield* awaitStop(current);
582
+ if (generation === current) generation = undefined;
583
+ });
584
+
585
+ const acquire = (screencast: ScreencastOptions) =>
586
+ lock.withPermits(1)(
587
+ Effect.gen(function* () {
588
+ if (Deferred.isDoneUnsafe(closed)) return yield* gone();
589
+ let current = generation;
590
+ const starting = current === undefined;
591
+
592
+ if (current === undefined) {
593
+ yield* awaitPreviousStop;
594
+ const [transport, { calibration, viewport }] = yield* prepare(options, screencast);
595
+ const size = screencast.size ?? viewport;
596
+
597
+ if (Deferred.isDoneUnsafe(closed)) return yield* gone();
598
+
599
+ const created: Generation = {
600
+ subscribers: new Set(),
601
+ transport,
602
+ unlisten: () => undefined,
603
+ calibration,
604
+ quality: screencast.quality ?? 80,
605
+ size,
606
+ accepting: true,
607
+ predecessor: undefined,
608
+ failure: undefined,
609
+ stopReply: undefined,
610
+ onFrame: (native) => {
611
+ if (!created.accepting) return;
612
+ counts.count("received");
613
+ if (replies.size >= replyCapacity) {
614
+ fail(
615
+ created,
616
+ options.error(new Error("screencast acknowledgement capacity exceeded")),
617
+ );
618
+
619
+ return;
620
+ }
621
+
622
+ const reply = created.transport.acknowledge(native.sessionId).then(
623
+ () => {
624
+ replies.delete(reply);
625
+ },
626
+ (cause: unknown) => {
627
+ replies.delete(reply);
628
+ if (created.accepting) fail(created, options.error(cause));
629
+ },
630
+ );
631
+
632
+ replies.add(reply);
633
+ const browserSeconds = native.metadata.timestamp;
634
+ const timestamp = browserSeconds === undefined ? Number.NaN : browserSeconds * 1000;
635
+
636
+ if (!Number.isFinite(timestamp)) return counts.count("missingTimestamp");
637
+ if (created.predecessor !== undefined && timestamp <= created.predecessor)
638
+ return counts.count("late");
639
+ Queue.offerUnsafe(renewing, undefined);
640
+ const { frame, image, device } = timed(options, native, timestamp, created);
641
+
642
+ if (pictures.drawn(frame, !sizes.expects(device)))
643
+ return counts.count("duringPictures");
644
+ sizes.offer(frame, timestamp, image, device);
645
+ },
646
+ };
647
+
648
+ current = created;
649
+ generation = current;
650
+ latest = Option.none();
651
+ sizes.restart(viewport);
652
+ } else if (
653
+ (screencast.quality !== undefined && screencast.quality !== current.quality) ||
654
+ (screencast.size !== undefined &&
655
+ (screencast.size.width !== current.size?.width ||
656
+ screencast.size.height !== current.size.height))
657
+ )
658
+ // One native capture serves every reader; silently giving a reader other settings
659
+ // than it asked for would misstate its frames.
660
+ return yield* undispatched(
661
+ "screencast",
662
+ new InvalidRequest({
663
+ detail: `a screencast with quality ${current.quality}${
664
+ current.size === null
665
+ ? ""
666
+ : ` and size ${current.size.width}x${current.size.height}`
667
+ } is already running on this page; read it without options or with the same ones`,
668
+ }),
669
+ );
670
+ // The callback API must apply sliding synchronously; PubSub.publishUnsafe skips it.
671
+ const subscription = yield* Queue.sliding<Envelope>(subscriberCapacity);
672
+
673
+ current.subscribers.add(subscription);
674
+ // A reader that joins a capture that has failed is told as its other readers are.
675
+ if (current.failure !== undefined)
676
+ Queue.offerUnsafe(subscription, { _tag: "Failure", error: current.failure });
677
+ const baseline = delivered;
678
+
679
+ if (starting) {
680
+ const { transport, quality, size } = current;
681
+ const listening = current;
682
+
683
+ current.unlisten = transport.listen({
684
+ frame: current.onFrame,
685
+ lost: (error) => fail(listening, error),
686
+ });
687
+
688
+ const screencast = Effect.tryPromise({
689
+ try: () =>
690
+ transport.start({
691
+ format: "jpeg",
692
+ quality,
693
+ ...(size === null ? {} : { maxWidth: size.width, maxHeight: size.height }),
694
+ }),
695
+ catch: options.error,
696
+ });
697
+
698
+ const started = yield* Effect.all([options.watch, screencast], {
699
+ concurrency: 2,
700
+ }).pipe(
701
+ Effect.interruptible,
702
+ Effect.timeoutOrElse({
703
+ duration: deadline,
704
+ orElse: () =>
705
+ Effect.fail(options.error(new Error("screencast start exceeded its deadline"))),
706
+ }),
707
+ Effect.provideService(Clock.Clock, options.clock),
708
+ Effect.exit,
709
+ );
710
+
711
+ if (Exit.isFailure(started)) {
712
+ yield* leave(current, subscription);
713
+
714
+ return yield* Effect.failCause(started.cause);
715
+ }
716
+ }
717
+
718
+ return { current, subscription, baseline };
719
+ }),
720
+ );
721
+
722
+ const stream: Controller["stream"] = (screencast = {}) =>
723
+ Stream.unwrap(
724
+ Effect.gen(function* () {
725
+ const lease = yield* Effect.acquireRelease(
726
+ acquire(screencast),
727
+ ({ current, subscription }) => lock.withPermits(1)(leave(current, subscription)),
728
+ );
729
+
730
+ let previous = lease.baseline;
731
+
732
+ // Pull one envelope at a time so taking a stream chunk cannot hide a slow reader's loss.
733
+ return Stream.fromEffectRepeat(
734
+ Queue.take(lease.subscription).pipe(
735
+ Effect.flatMap((envelope) => {
736
+ if (envelope._tag === "Failure") return why(envelope.error);
737
+
738
+ return Effect.sync(() => {
739
+ if (envelope.sequence > previous + 1)
740
+ counts.count("lost", envelope.sequence - previous - 1);
741
+ previous = envelope.sequence;
742
+
743
+ return envelope.frame;
744
+ });
745
+ }),
746
+ ),
747
+ );
748
+ }),
749
+ );
750
+
751
+ // The page's scope closes once the page has gone, when its browser's loss, if any, is known.
752
+ yield* Effect.addFinalizer(() =>
753
+ lock.withPermits(1)(
754
+ Effect.suspend(() => {
755
+ const current = generation;
756
+
757
+ Deferred.doneUnsafe(closed, Exit.void);
758
+ if (current === undefined) return Effect.void;
759
+ fail(current, gone());
760
+
761
+ return awaitStop(current);
762
+ }),
763
+ ),
764
+ );
765
+
766
+ return {
767
+ stream,
768
+ latest: Effect.sync(() => latest),
769
+ recent: Effect.sync(() => history),
770
+ active: Effect.sync(() => generation?.accepting === true),
771
+ sync: Effect.suspend(() => synced(generation, why, options.error)),
772
+ excluding: pictures.excluding,
773
+ stats: (windowMillis?: number) =>
774
+ Effect.sync(
775
+ () => new CaptureStats({ ...counts.read(windowMillis), ackBacklog: replies.size }),
776
+ ),
777
+ } satisfies Controller;
778
+ });