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,386 @@
1
+ /**
2
+ * The bridge to the page script, and the page's documents as its own session sees them commit and
3
+ * load, so a load follows its commit. At the library's first read of a page, its own protocol
4
+ * session registers the script, so every later document runs it in an isolated world from its
5
+ * start; calls go to the current document's world. The script is composed from the domains'
6
+ * page-side parts, the `*.inpage.ts` modules, in the order they depend on one another. At the first
7
+ * read of a page's changes, its session registers the recorder too, so every later document
8
+ * records from its start.
9
+ */
10
+ import { Effect, Schema, SynchronizedRef } from "effect";
11
+
12
+ import { type BrowserError, Failed } from "../../BrowserError.ts";
13
+ import { Navigated, PageLoaded } from "../../BrowserEvent.ts";
14
+ import { edit, type Edit } from "../input/edit.inpage.ts";
15
+ import { evidence } from "../input/evidence.inpage.ts";
16
+ import { guard, type Guard } from "../input/guard.inpage.ts";
17
+ import { targets, type Targets } from "../input/targets.inpage.ts";
18
+ import { context } from "../reading/context.inpage.ts";
19
+ import { controls, type Controls } from "../reading/controls.inpage.ts";
20
+ import { expectations, type Expectations } from "../reading/expect.inpage.ts";
21
+ import { match } from "../reading/match.inpage.ts";
22
+ import { names } from "../reading/names.inpage.ts";
23
+ import { outline, type Outline } from "../reading/outline.inpage.ts";
24
+ import { ready } from "../reading/ready.inpage.ts";
25
+ import { subjects, type Subjects } from "../reading/subjects.inpage.ts";
26
+ import { text, type Texts } from "../reading/text.inpage.ts";
27
+ import { walk } from "../reading/walk.inpage.ts";
28
+ import { changes, type Changes } from "../timeline/changes.inpage.ts";
29
+ import { fold } from "../timeline/fold.inpage.ts";
30
+ import { history } from "../timeline/history.inpage.ts";
31
+ import { marks } from "../timeline/marks.inpage.ts";
32
+ import { record, type Recorder } from "../timeline/record.inpage.ts";
33
+ import { sight } from "../timeline/sight.inpage.ts";
34
+ import { contextGone, failWith, type PageContext, undispatched } from "./context.ts";
35
+ import * as Url from "./url.ts";
36
+
37
+ /** What the script installs: a version, and a function of one of its parts for each call. */
38
+ export interface PageApi {
39
+ readonly version: number;
40
+ snapshot: Outline["snapshot"];
41
+ find: Subjects["find"];
42
+ until: Expectations["until"];
43
+ waitUntil: Controls["waitUntil"];
44
+ text: Texts["read"];
45
+ record: Recorder["start"];
46
+ changes: Changes["read"];
47
+ ready: ReturnType<typeof ready>["wait"];
48
+ settle: ReturnType<typeof ready>["settle"];
49
+ point: Targets["point"];
50
+ scrollPlan: Targets["scrollPlan"];
51
+ viewport: Outline["viewport"];
52
+ /** The screen as the page reads it, which another session may emulate. */
53
+ screenSize: () => { readonly width: number; readonly height: number };
54
+ prepareInput: Guard["prepareInput"];
55
+ validateInput: Guard["validateInput"];
56
+ typeable: Edit["typeable"];
57
+ focus: Edit["focus"];
58
+ focusOn: Edit["focusOn"];
59
+ select: Edit["select"];
60
+ }
61
+
62
+ declare global {
63
+ // The API this script installs, which exists only inside the library's isolated world.
64
+ var __effectBrowser: PageApi | undefined;
65
+ }
66
+
67
+ // Like the parts it receives, this is evaluated from its source text.
68
+ const install = (
69
+ makeNames: typeof names,
70
+ makeWalk: typeof walk,
71
+ makeMatch: typeof match,
72
+ makeContext: typeof context,
73
+ makeSubjects: typeof subjects,
74
+ makeExpectations: typeof expectations,
75
+ makeText: typeof text,
76
+ makeControls: typeof controls,
77
+ makeFold: typeof fold,
78
+ makeHistory: typeof history,
79
+ makeSight: typeof sight,
80
+ makeMarks: typeof marks,
81
+ makeRecord: typeof record,
82
+ makeChanges: typeof changes,
83
+ makeReady: typeof ready,
84
+ makeOutline: typeof outline,
85
+ makeTargets: typeof targets,
86
+ makeEvidence: typeof evidence,
87
+ makeGuard: typeof guard,
88
+ makeEdit: typeof edit,
89
+ makeAddresses: typeof Url.addresses,
90
+ ): PageApi => {
91
+ const installed = globalThis.__effectBrowser;
92
+
93
+ if (installed !== undefined && installed.version === 18) return installed;
94
+ const named = makeNames();
95
+ const walked = makeWalk(named);
96
+ const placing = makeContext(named, walked);
97
+ const subjected = makeSubjects(named, walked, makeMatch(), placing);
98
+ const texts = makeText(named, walked);
99
+ const controlled = makeControls(named, walked, subjected, texts);
100
+ const kept = makeHistory(makeFold());
101
+ const seeing = makeSight(named, walked, texts, kept);
102
+ const marking = makeMarks(named, kept, seeing);
103
+ const recorder = makeRecord(named, texts, kept, seeing, marking);
104
+ const read = makeOutline(named, walked, subjected, texts, controlled, makeAddresses());
105
+ const located = makeTargets(named, walked, placing);
106
+ const guarded = makeGuard(named, located, makeEvidence(named, placing));
107
+ const edited = makeEdit(named, guarded, placing);
108
+ const readiness = makeReady(walked, texts, kept);
109
+
110
+ const api: PageApi = {
111
+ version: 18,
112
+ snapshot: read.snapshot,
113
+ find: subjected.find,
114
+ until: makeExpectations(named, subjected).until,
115
+ waitUntil: controlled.waitUntil,
116
+ text: texts.read,
117
+ record: recorder.start,
118
+ changes: makeChanges(named, placing, kept, seeing, marking, recorder).read,
119
+ ready: readiness.wait,
120
+ settle: readiness.settle,
121
+ point: located.point,
122
+ scrollPlan: located.scrollPlan,
123
+ viewport: read.viewport,
124
+ screenSize: () => ({ width: screen.width, height: screen.height }),
125
+ prepareInput: guarded.prepareInput,
126
+ validateInput: guarded.validateInput,
127
+ typeable: edited.typeable,
128
+ focus: edited.focus,
129
+ focusOn: edited.focusOn,
130
+ select: edited.select,
131
+ };
132
+
133
+ globalThis.__effectBrowser = api;
134
+
135
+ return api;
136
+ };
137
+
138
+ /** The expression that installs the script and evaluates to its API. */
139
+ export const installSource = `(${install.toString()})(${[names, walk, match, context, subjects, expectations, text, controls, fold, history, sight, marks, record, changes, ready, outline, targets, evidence, guard, edit, Url.addresses].join(", ")})`;
140
+
141
+ const worldName = "effect-browser";
142
+
143
+ // What starts the recorder in each new document, after the script installed itself there.
144
+ const recorderSource = `if (globalThis === globalThis.top) globalThis.__effectBrowser?.record();`;
145
+
146
+ // What a call reads in a world without the script's API. A context id can name another world once
147
+ // the page has moved to a new process, so a cached id that reads this is not the library's world.
148
+ const missing = "effect-browser: no __effectBrowser in this world";
149
+
150
+ const isMissing = (error: BrowserError) =>
151
+ error.reason._tag === "Failed" && error.reason.detail === missing;
152
+
153
+ /** A frame as a page session reports its commit. */
154
+ export const Commit = Schema.Struct({
155
+ parentId: Schema.optionalKey(Schema.String),
156
+ loaderId: Schema.String,
157
+ url: Schema.String,
158
+ urlFragment: Schema.optionalKey(Schema.String),
159
+ });
160
+
161
+ /** `Page.frameNavigated` or `Page.navigatedWithinDocument`, as far as `mainFrame` reads them. */
162
+ export const MainFrameEvent = Schema.Union([
163
+ Schema.Struct({ frame: Commit }),
164
+ Schema.Struct({ frameId: Schema.String, url: Schema.String }),
165
+ ]);
166
+
167
+ /** A commit's loader and its address, redacted. */
168
+ export const commitOf = ({ loaderId, url, urlFragment = "" }: typeof Commit.Type) => ({
169
+ loader: loaderId,
170
+ url: Url.redact(url + urlFragment),
171
+ });
172
+
173
+ /**
174
+ * What a page session's commit or move says of `page`'s main frame: a new document's `loader` and
175
+ * its address, or a move's address; nothing for another frame. Only a commit is a new document:
176
+ * `frameStartedLoading` also fires on `pushState`, a move within the document.
177
+ */
178
+ export const mainFrame = (page: string, event: typeof MainFrameEvent.Type) =>
179
+ "frame" in event
180
+ ? event.frame.parentId === undefined
181
+ ? commitOf(event.frame)
182
+ : undefined
183
+ : event.frameId === page
184
+ ? { loader: undefined, url: Url.redact(event.url) }
185
+ : undefined;
186
+
187
+ /** A call to the page script, its arguments quoted as JavaScript literals. */
188
+ export const scriptCall = (name: string, ...args: ReadonlyArray<unknown>): string =>
189
+ `${name}(${args.map((arg) => JSON.stringify(arg)).join(", ")})`;
190
+
191
+ export const make = Effect.fnUntraced(function* (page: PageContext) {
192
+ const { id, cdp, native, span, publish, now } = page;
193
+ const { send } = page.protocol;
194
+ // How far this session has registered: the script, then the recorder, which the first read of
195
+ // changes asks for. One caller takes each step, once, while the others wait.
196
+ const registration = yield* SynchronizedRef.make<"none" | "script" | "recorder">("none");
197
+ // The current document's world, forgotten when the main frame commits another document; the
198
+ // commits this session has seen, the latest marked on the page; the page's address; and how far
199
+ // the current document has loaded.
200
+ let world: number | undefined;
201
+ let documents = 0;
202
+ let url = Url.redact(page.url);
203
+ let loaded: PageLoaded | undefined;
204
+ // The latest commits' loaders and the documents they began, newest last: a capture connection
205
+ // sees the same commits, and numbers its frames by them.
206
+ const loaders = new Map<string, number>();
207
+
208
+ const onMainFrame = (event: typeof MainFrameEvent.Type) => {
209
+ const seen = mainFrame(id, event);
210
+
211
+ if (seen === undefined) return;
212
+ if (seen.loader !== undefined) {
213
+ documents++;
214
+ loaders.delete(seen.loader);
215
+ loaders.set(seen.loader, documents);
216
+ const [oldest] = loaders.keys();
217
+
218
+ if (loaders.size > 8 && oldest !== undefined) loaders.delete(oldest);
219
+ page.activity.documentAt = now();
220
+ world = undefined;
221
+ loaded = undefined;
222
+ }
223
+ url = seen.url;
224
+ const sameDocument = seen.loader === undefined;
225
+
226
+ publish(new Navigated({ at: now(), page: id, url, document: documents, sameDocument }));
227
+ };
228
+
229
+ // The session tells these of the main frame alone.
230
+ const onLoaded = (state: PageLoaded["state"]) => () => {
231
+ loaded = new PageLoaded({ at: now(), page: id, state });
232
+ publish(loaded);
233
+ };
234
+
235
+ cdp.on("Page.frameNavigated", onMainFrame);
236
+ cdp.on("Page.navigatedWithinDocument", onMainFrame);
237
+ cdp.on("Page.domContentEventFired", onLoaded("domcontentloaded"));
238
+ cdp.on("Page.loadEventFired", onLoaded("load"));
239
+
240
+ /**
241
+ * The main-frame commits seen so far, once the Page domain, which registration turned on, has
242
+ * answered, so that every later one counts. A commit within about one round trip of the count
243
+ * can still be missed.
244
+ */
245
+ const currentDocument = (operation: string) =>
246
+ page.paging(operation).pipe(Effect.map(() => documents));
247
+
248
+ /** What a frame that arrives now shows: its session, the page's current document and address. */
249
+ const frameTag = () => ({ session: page.session, document: documents, url });
250
+
251
+ /** The document the main frame's latest commit of `loader` began, if this session counted it. */
252
+ const documentOf = (loader: string) => loaders.get(loader);
253
+
254
+ // The registration belongs to this session and the world to the page, so a session that attaches
255
+ // later, as after a reconnect, registers again and finds the script already installed. With the
256
+ // Page domain enabled, a source runs in each new document, in the order registered;
257
+ // `runImmediately` runs it now.
258
+ const addScript = (operation: string, source: string) =>
259
+ native(operation, () =>
260
+ send("Page.addScriptToEvaluateOnNewDocument", { source, worldName, runImmediately: true }),
261
+ );
262
+
263
+ const register = (operation: string) =>
264
+ Effect.all(
265
+ [
266
+ page.paging(operation),
267
+ addScript(operation, `if (globalThis === globalThis.top) ${installSource};`),
268
+ ],
269
+ { concurrency: "unbounded" },
270
+ ).pipe(span("Page.register", {}, "Debug"));
271
+
272
+ /** Take registration from `from` to `to` by `step`, unless it is past `from` already. */
273
+ const advance = (
274
+ from: "none" | "script",
275
+ to: "script" | "recorder",
276
+ step: Effect.Effect<unknown, BrowserError>,
277
+ ) =>
278
+ SynchronizedRef.updateEffect(registration, (state) =>
279
+ state === from ? Effect.as(step, to) : Effect.succeed(state),
280
+ );
281
+
282
+ /** Register the script once, and the recorder after it once a read of changes asks for it. */
283
+ const ensure = (operation: string, recorder: boolean) =>
284
+ Effect.andThen(
285
+ advance("none", "script", register(operation)),
286
+ recorder ? advance("script", "recorder", addScript(operation, recorderSource)) : Effect.void,
287
+ );
288
+
289
+ const createWorld = (operation: string) =>
290
+ Effect.gen(function* () {
291
+ yield* ensure(operation, false);
292
+ const document = documents;
293
+
294
+ // The main frame's id is the page's target id.
295
+ const created = yield* native(operation, () =>
296
+ send("Page.createIsolatedWorld", { frameId: id, worldName }),
297
+ );
298
+
299
+ // A world the page replaced while it was being looked up serves this call, not later ones.
300
+ if (document === documents) world = created.executionContextId;
301
+
302
+ return created.executionContextId;
303
+ }).pipe(span("Page.createWorld", {}, "Debug"));
304
+
305
+ /** The current document's world, created if it has none yet. */
306
+ const current = (operation: string) =>
307
+ Effect.suspend(() => (world === undefined ? createWorld(operation) : Effect.succeed(world)));
308
+
309
+ /** A world of the current document without the page script, as the clock probe needs. */
310
+ const bareWorld = (operation: string) =>
311
+ native(operation, () =>
312
+ send("Page.createIsolatedWorld", { frameId: id, worldName: `${worldName}-clock` }),
313
+ ).pipe(Effect.map((created) => created.executionContextId));
314
+
315
+ const run = (operation: string, expression: string, contextId: number) =>
316
+ native(operation, () =>
317
+ send("Runtime.evaluate", { contextId, expression, returnByValue: true, awaitPromise: true }),
318
+ ).pipe(
319
+ Effect.flatMap(({ result, exceptionDetails: thrown }) =>
320
+ thrown === undefined
321
+ ? Effect.succeed<unknown>(result.value)
322
+ : failWith(
323
+ operation,
324
+ new Failed({ detail: thrown.exception?.description ?? thrown.text }),
325
+ ),
326
+ ),
327
+ );
328
+
329
+ const evaluateIn = (operation: string, call: string, contextId: number) =>
330
+ run(
331
+ operation,
332
+ `globalThis.__effectBrowser === undefined ? ${JSON.stringify(missing)} : globalThis.__effectBrowser.${call}`,
333
+ contextId,
334
+ ).pipe(
335
+ Effect.filterOrFail(
336
+ (value) => value !== missing,
337
+ () => undispatched(operation, new Failed({ detail: missing })),
338
+ ),
339
+ // One round trip to the page. Its arguments can hold typed text, so only the call is named.
340
+ span("Page.evaluate", { function: call.split("(")[0] }, "Trace"),
341
+ );
342
+
343
+ // Ordinary reads can recreate a document's world. Approval validation deliberately cannot. A
344
+ // world found by its name that lacks the script, in a document the registration missed, gets it.
345
+ const evaluateWithContext = (operation: string, call: string) => {
346
+ const attempt = (contextId: number) =>
347
+ evaluateIn(operation, call, contextId).pipe(Effect.map((value) => ({ contextId, value })));
348
+
349
+ const again = createWorld(operation).pipe(
350
+ Effect.flatMap((contextId) =>
351
+ attempt(contextId).pipe(
352
+ Effect.catchIf(isMissing, () =>
353
+ run(operation, `${installSource}.version`, contextId).pipe(
354
+ Effect.andThen(attempt(contextId)),
355
+ ),
356
+ ),
357
+ ),
358
+ ),
359
+ );
360
+
361
+ return current(operation).pipe(
362
+ Effect.flatMap(attempt),
363
+ Effect.catchIf(contextGone, () => again),
364
+ );
365
+ };
366
+
367
+ const evaluate = (operation: string, call: string) =>
368
+ evaluateWithContext(operation, call).pipe(Effect.map(({ value }) => value));
369
+
370
+ return {
371
+ current,
372
+ bareWorld,
373
+ evaluate,
374
+ evaluateIn,
375
+ evaluateWithContext,
376
+ currentDocument,
377
+ frameTag,
378
+ documentOf,
379
+ /** How far the current document has loaded, as this session heard; nothing until parsed. */
380
+ loaded: () => loaded,
381
+ /** Register the recorder, so every later document records from its start. */
382
+ registerRecorder: (operation: string) => ensure(operation, true),
383
+ };
384
+ });
385
+
386
+ export type Bridge = Effect.Success<ReturnType<typeof make>>;
@@ -0,0 +1,302 @@
1
+ /**
2
+ * What a page's parts share: the browser state they act on, the page's own lane, pointer and
3
+ * clock, and how they call the browser and report its failures. The assembly in `page.ts` builds
4
+ * one context per page.
5
+ */
6
+ import {
7
+ Clock,
8
+ Context,
9
+ Duration,
10
+ Effect,
11
+ Fiber,
12
+ MutableRef,
13
+ Option,
14
+ Schema,
15
+ type Scope,
16
+ } from "effect";
17
+ import type { CDPSession, Page as PlaywrightPage } from "playwright-core";
18
+
19
+ import type { CaptureSource } from "../../Browser.ts";
20
+ import { BrowserError, Closed, Failed, type Reason, Timeout } from "../../BrowserError.ts";
21
+ import type { BrowserEvent } from "../../BrowserEvent.ts";
22
+ import type { InputGuard, Point } from "../../Page.ts";
23
+ import type * as BrowserClock from "../pictures/clock.ts";
24
+ import * as Lane from "./lane.ts";
25
+ import type { Openings } from "./openings.ts";
26
+ import * as Url from "./url.ts";
27
+
28
+ /** Why a page is gone. */
29
+ export type ClosedCause = Closed["cause"];
30
+
31
+ /** What every page of a browser shares, from its options. */
32
+ export interface Settings {
33
+ readonly actionTimeout: Duration.Duration;
34
+ readonly policyTimeout: Duration.Duration;
35
+ readonly navigationTimeout: Duration.Duration;
36
+ /** How long screencast frames stay in `recentFrames`, measured back from the newest. */
37
+ readonly frameHistory: Duration.Duration;
38
+ readonly guard: InputGuard | undefined;
39
+ }
40
+
41
+ export interface MakeOptions {
42
+ /** The page's CDP target id, which is also its main frame's id. */
43
+ readonly id: string;
44
+ /** The id of the browser's session, which its frames carry. */
45
+ readonly session: string;
46
+ /** The page's address when the browser began tracking it. */
47
+ readonly url: string;
48
+ readonly playwright: PlaywrightPage;
49
+ readonly cdp: CDPSession;
50
+ /** A call's reply, or a failure as for a closed page once the browser is lost first. */
51
+ readonly untilLost: <A>(reply: Promise<A>) => Promise<A>;
52
+ readonly settings: Settings;
53
+ readonly clock: Clock.Clock;
54
+ /** The browser's epoch mapping: its first capture measures it, and later ones renew it. */
55
+ readonly mapping: BrowserClock.Mapping;
56
+ readonly publish: (event: BrowserEvent) => number;
57
+ /** This page's retained events, oldest first. */
58
+ readonly recentEvents: Effect.Effect<ReadonlyArray<BrowserEvent>>;
59
+ /** Registration of the actual popups this page's current input opened. */
60
+ readonly openings: Openings;
61
+ /** Succeeds once the page's own session holds focus emulation, which keeps it painting behind. */
62
+ readonly focused: Effect.Effect<void, BrowserError>;
63
+ /** Succeeds once the page's own session has the Page domain on, so it sees every later commit. */
64
+ readonly paging: (operation: string) => Effect.Effect<void, BrowserError>;
65
+ /** Why the page is gone, once it is: its browser's loss, else its own close or crash. */
66
+ readonly closedBy: () => ClosedCause;
67
+ /** Where the page's screencast runs, if not on its own session. */
68
+ readonly capture?: CaptureSource | undefined;
69
+ }
70
+
71
+ const messageOf = (cause: unknown): string =>
72
+ cause instanceof Error ? cause.message : typeof cause === "string" ? cause : "unknown error";
73
+
74
+ const closedPattern =
75
+ /has been closed|Target closed|Session closed|browser has disconnected|Target page, context or browser/i;
76
+
77
+ /**
78
+ * Map a Playwright or protocol failure to a reason, `Closed` with `closed` as its cause for a page
79
+ * or browser that is gone. A call that gives Playwright a timeout passes the same bound, so its
80
+ * `Timeout` reports it; without one, Playwright's own message stays.
81
+ */
82
+ export const reasonOf = (
83
+ cause: unknown,
84
+ timeoutMillis?: number,
85
+ closed: ClosedCause = "connection",
86
+ ): Reason => {
87
+ const message = messageOf(cause);
88
+
89
+ if (closedPattern.test(message)) return new Closed({ cause: closed });
90
+ if (cause instanceof Error && cause.name === "TimeoutError" && timeoutMillis !== undefined)
91
+ return new Timeout({ millis: timeoutMillis });
92
+ const line = message.split("\n")[0] ?? message;
93
+
94
+ return new Failed({ detail: Url.redactWithin(line.replace(/^[\w.]+: /, "")) });
95
+ };
96
+
97
+ /**
98
+ * One Playwright or protocol call, failing undispatched. A closed page says why it is gone as the
99
+ * browser knows then: the library's own calls fail with the browser's loss, and Playwright fails
100
+ * its calls on a dropped connection after reporting the context closed.
101
+ */
102
+ export const call = <A>(
103
+ operation: string,
104
+ run: () => Promise<A>,
105
+ closedBy: () => ClosedCause,
106
+ timeoutMillis?: number,
107
+ ) =>
108
+ Effect.tryPromise({
109
+ try: run,
110
+ catch: (cause) => undispatched(operation, reasonOf(cause, timeoutMillis, closedBy())),
111
+ });
112
+
113
+ export const contextGone = (error: BrowserError) =>
114
+ error.reason._tag === "Failed" &&
115
+ /Cannot find context|Execution context was destroyed|__effectBrowser|Inspected target navigated/i.test(
116
+ error.reason.detail,
117
+ );
118
+
119
+ /**
120
+ * What a page operation spent on the page's own protocol session: its calls, the bytes of their
121
+ * parameters and results, and how long at least one of them awaited its reply. An operation inside
122
+ * another counts toward both.
123
+ */
124
+ interface Cost {
125
+ readonly enclosing: Cost | undefined;
126
+ calls: number;
127
+ bytesOut: number;
128
+ bytesIn: number;
129
+ waitedMillis: number;
130
+ awaiting: number;
131
+ awaitingSince: number;
132
+ }
133
+
134
+ /** The cost of the page operation the current fiber runs, if any. */
135
+ const CurrentCost = Context.Reference<Cost | undefined>(
136
+ "effect-browser/internal/page/CurrentCost",
137
+ {
138
+ defaultValue: () => undefined,
139
+ },
140
+ );
141
+
142
+ // A message's size on the wire, give or take its envelope.
143
+ const sizeOf = (value: unknown) =>
144
+ value === undefined ? 0 : Buffer.byteLength(JSON.stringify(value));
145
+
146
+ /** A failure of `operation` before any of its input reached the browser. */
147
+ export const undispatched = (operation: string, reason: Reason) =>
148
+ new BrowserError({ operation, reason, dispatched: false });
149
+
150
+ export const failWith = (operation: string, reason: Reason) =>
151
+ Effect.fail(undispatched(operation, reason));
152
+
153
+ export const decodeWith =
154
+ <A>(operation: string, schema: Schema.Codec<A, unknown>) =>
155
+ (value: unknown) =>
156
+ Schema.decodeUnknownEffect(schema)(value).pipe(
157
+ Effect.mapError((error) => undispatched(operation, new Failed({ detail: error.message }))),
158
+ );
159
+
160
+ export const make = (options: MakeOptions, scope: Scope.Scope) => {
161
+ const { id, clock } = options;
162
+ const now = () => Number(clock.monotonicTimeNanosUnsafe()) / 1e6;
163
+ // Deadlines and pacing guard the page's lane, so page operations run on the owner's clock; a
164
+ // caller's clock, such as a TestClock, cannot stall or stretch them.
165
+ const owned = Effect.provideService(Clock.Clock, clock);
166
+
167
+ // This page's spans name it and report what the operation cost on the protocol. Fine-grained
168
+ // ones carry a level below the default Info, so an application keeps only the coarse ones by
169
+ // raising `Tracer.MinimumTraceLevel`.
170
+ const span =
171
+ (name: string, attributes: Record<string, unknown> = {}, level?: "Debug" | "Trace") =>
172
+ <A, E, R>(effect: Effect.Effect<A, E, R>) =>
173
+ Effect.gen(function* () {
174
+ const cost: Cost = {
175
+ enclosing: yield* CurrentCost,
176
+ calls: 0,
177
+ bytesOut: 0,
178
+ bytesIn: 0,
179
+ waitedMillis: 0,
180
+ awaiting: 0,
181
+ awaitingSince: 0,
182
+ };
183
+
184
+ return yield* effect.pipe(
185
+ Effect.provideService(CurrentCost, cost),
186
+ Effect.ensuring(
187
+ Effect.suspend(() =>
188
+ Effect.annotateCurrentSpan({
189
+ calls: cost.calls,
190
+ bytesOut: cost.bytesOut,
191
+ bytesIn: cost.bytesIn,
192
+ waitedMillis: Math.round(cost.waitedMillis * 10) / 10,
193
+ }),
194
+ ),
195
+ ),
196
+ );
197
+ }).pipe(
198
+ Effect.withSpan(
199
+ name,
200
+ { attributes: { page: id, ...attributes }, ...(level === undefined ? {} : { level }) },
201
+ { captureStackTrace: false },
202
+ ),
203
+ );
204
+
205
+ // Every call on the page's own protocol session, counted into the operation that sends it and
206
+ // the operations around that one. It is looked up when the call is sent, so input pipelined
207
+ // from inside an operation counts toward it too.
208
+ const send: CDPSession["send"] = (method, params) => {
209
+ const cost = Fiber.getCurrent()?.getRef(CurrentCost);
210
+ const sentAt = now();
211
+ const bytesOut = sizeOf(params);
212
+
213
+ for (let each = cost; each !== undefined; each = each.enclosing) {
214
+ each.calls++;
215
+ each.bytesOut += bytesOut;
216
+ if (each.awaiting++ === 0) each.awaitingSince = sentAt;
217
+ }
218
+ const reply = options.untilLost(options.cdp.send(method, params));
219
+
220
+ if (cost === undefined) return reply;
221
+
222
+ const settle = (bytesIn: number) => {
223
+ const at = now();
224
+
225
+ for (let each: Cost | undefined = cost; each !== undefined; each = each.enclosing) {
226
+ each.bytesIn += bytesIn;
227
+ if (--each.awaiting === 0) each.waitedMillis += at - each.awaitingSince;
228
+ }
229
+ };
230
+
231
+ return reply.then(
232
+ (result) => {
233
+ settle(sizeOf(result));
234
+
235
+ return result;
236
+ },
237
+ (cause: unknown) => {
238
+ settle(0);
239
+
240
+ return Promise.reject(cause);
241
+ },
242
+ );
243
+ };
244
+
245
+ // Every protocol or Playwright call. Errors are undispatched here; `perform` marks them
246
+ // dispatched once input has gone out.
247
+ const native = <A>(operation: string, run: () => Promise<A>, timeoutMillis?: number) =>
248
+ call(operation, run, options.closedBy, timeoutMillis);
249
+
250
+ /** Fail with `Timeout` once `duration` has passed, the action timeout unless another is given. */
251
+ const within =
252
+ (operation: string, duration: Duration.Input = options.settings.actionTimeout) =>
253
+ <A, E, R>(effect: Effect.Effect<A, E, R>) =>
254
+ Effect.timeoutOrElse(effect, {
255
+ duration,
256
+ orElse: () => failWith(operation, new Timeout({ millis: Duration.toMillis(duration) })),
257
+ });
258
+
259
+ // The latest submitted input or page change. A read that must follow it waits for the action in
260
+ // flight, so only paint from after its latest input is current. An action ends after its input
261
+ // was handled, so if that input changed the page, newer paint follows; a lost final paint is
262
+ // bounded by recency instead. Paint from before the current document began, when the page's own
263
+ // session saw the main frame commit it, shows a page the tab has left, so no read reuses it,
264
+ // whatever it asks.
265
+ const activity = { inputAt: 0, documentAt: Number.NEGATIVE_INFINITY };
266
+
267
+ const noteInput = () => {
268
+ activity.inputAt = Math.max(activity.inputAt, now());
269
+ };
270
+
271
+ const lane = Lane.make({
272
+ now,
273
+ actionTimeout: options.settings.actionTimeout,
274
+ documentAt: () => activity.documentAt,
275
+ gone: (operation) => undispatched(operation, new Closed({ cause: options.closedBy() })),
276
+ scope,
277
+ });
278
+
279
+ // Where this page's latest move left its pointer, which is where its next plain glide starts and
280
+ // where a held button is released. Its lane orders the page's input, and Chromium keeps a
281
+ // pointer per page too.
282
+ const pointer = MutableRef.make(Option.none<Point>());
283
+
284
+ // Named, so that the declarations of what holds it need not spell out Playwright's protocol types.
285
+ const protocol: Pick<CDPSession, "send"> = { send };
286
+
287
+ return {
288
+ ...options,
289
+ lane,
290
+ pointer,
291
+ now,
292
+ owned,
293
+ span,
294
+ protocol,
295
+ native,
296
+ within,
297
+ activity,
298
+ noteInput,
299
+ };
300
+ };
301
+
302
+ export type PageContext = ReturnType<typeof make>;