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,301 @@
1
+ /**
2
+ * A page's admission: the one lane every operation on the page takes its turn in, so a page waits
3
+ * only for itself. A write, an action that sends input or navigates, holds the lane alone, in the
4
+ * order writes asked; reads share it, after the write in flight and every write asked before them,
5
+ * so a read describes the page an action left, never one an action is still changing. Each write
6
+ * starts a new epoch as it asks. A turn not given in time fails `Busy`, never `Timeout`, and a
7
+ * caller under `FailFast` gets it at once.
8
+ *
9
+ * Reads keep their work. An identical read asked in the same epoch and document joins the one in
10
+ * flight, which runs in the page's scope under the action timeout whoever gives up; one that ends
11
+ * after all its callers gave up serves the next to ask the same, within an action timeout. A write
12
+ * stops the reads nobody awaits, since no caller after it could use them.
13
+ *
14
+ * Effect's `Semaphore` is not fair to a writer that needs every permit, so the rule is this
15
+ * module's own: one state, changed by `step` alone, which the model test drives.
16
+ */
17
+ import { Context, Deferred, Duration, Effect, Exit, type Fiber, type Scope } from "effect";
18
+
19
+ import { BrowserError, Busy } from "../../BrowserError.ts";
20
+
21
+ export type Kind = "read" | "write";
22
+
23
+ export interface Ticket {
24
+ readonly id: number;
25
+ readonly kind: Kind;
26
+ }
27
+
28
+ /** Who holds the lane: no one, reads together, or one write. */
29
+ export type Turn =
30
+ | { readonly _tag: "Free" }
31
+ | { readonly _tag: "Reading"; readonly count: number }
32
+ | { readonly _tag: "Writing" };
33
+
34
+ export interface State {
35
+ readonly turn: Turn;
36
+ /** The tickets waiting, in the order they asked. */
37
+ readonly queue: ReadonlyArray<Ticket>;
38
+ /** The writes asked so far: two reads in one epoch follow the same writes. */
39
+ readonly epoch: number;
40
+ }
41
+
42
+ export type Input =
43
+ | { readonly _tag: "Ask"; readonly ticket: Ticket }
44
+ /** The ticket gave up waiting, or ended its turn. */
45
+ | { readonly _tag: "Leave"; readonly ticket: Ticket };
46
+
47
+ export const initial: State = { turn: { _tag: "Free" }, queue: [], epoch: 0 };
48
+
49
+ const holders = (turn: Turn) =>
50
+ turn._tag === "Reading" ? turn.count : turn._tag === "Writing" ? 1 : 0;
51
+
52
+ /** Whether a ticket of `kind` asking now would hold the lane at once. */
53
+ export const admitsNow = ({ turn, queue }: State, kind: Kind) =>
54
+ queue.length === 0 && (kind === "read" ? turn._tag !== "Writing" : turn._tag === "Free");
55
+
56
+ /**
57
+ * The operations ahead of a waiting ticket: those holding the lane and those asked before it, or
58
+ * all that are queued for a ticket not in the queue.
59
+ */
60
+ export const ahead = ({ turn, queue }: State, id?: number) => {
61
+ const at = queue.findIndex((ticket) => ticket.id === id);
62
+
63
+ return holders(turn) + (at === -1 ? queue.length : at);
64
+ };
65
+
66
+ /**
67
+ * The lane after `input`, and the tickets it lets in. Tickets go in from the head of the queue
68
+ * only: a write once the lane is free, reads while no write holds it, so reads asked before a
69
+ * write go before it, and reads asked after it wait for it.
70
+ */
71
+ export const step = (
72
+ state: State,
73
+ input: Input,
74
+ ): { readonly state: State; readonly admitted: ReadonlyArray<Ticket> } => {
75
+ let { turn, queue, epoch } = state;
76
+
77
+ if (input._tag === "Ask") {
78
+ queue = [...queue, input.ticket];
79
+ if (input.ticket.kind === "write") epoch += 1;
80
+ } else {
81
+ const at = queue.findIndex((ticket) => ticket.id === input.ticket.id);
82
+
83
+ if (at !== -1) queue = queue.toSpliced(at, 1);
84
+ else
85
+ turn =
86
+ turn._tag === "Reading" && turn.count > 1
87
+ ? { _tag: "Reading", count: turn.count - 1 }
88
+ : { _tag: "Free" };
89
+ }
90
+
91
+ const admitted: Array<Ticket> = [];
92
+
93
+ for (const ticket of queue) {
94
+ if (ticket.kind === "write" ? turn._tag !== "Free" : turn._tag === "Writing") break;
95
+ admitted.push(ticket);
96
+ turn =
97
+ ticket.kind === "write" ? { _tag: "Writing" } : { _tag: "Reading", count: holders(turn) + 1 };
98
+ }
99
+
100
+ return { state: { turn, queue: queue.slice(admitted.length), epoch }, admitted };
101
+ };
102
+
103
+ /** Whether operations on a busy page fail `Busy` at once rather than wait their turn. */
104
+ export const FailFast = Context.Reference<boolean>("effect-browser/internal/page/FailFast", {
105
+ defaultValue: () => false,
106
+ });
107
+
108
+ /** The id a caller gives the actions it asks for, which each records as its `correlation`. */
109
+ export const Correlation = Context.Reference<string | undefined>(
110
+ "effect-browser/internal/page/Correlation",
111
+ { defaultValue: () => undefined },
112
+ );
113
+
114
+ /** A shared read in flight: its epoch, its result once it ends, and how many callers await it. */
115
+ interface Flight<A> {
116
+ readonly epoch: number;
117
+ readonly result: Deferred.Deferred<A, BrowserError>;
118
+ waiting: number;
119
+ fiber: Fiber.Fiber<A, BrowserError> | undefined;
120
+ }
121
+
122
+ export const make = (options: {
123
+ readonly now: () => number;
124
+ readonly actionTimeout: Duration.Duration;
125
+ /** When the page's current document began: a read is shared only within one. */
126
+ readonly documentAt: () => number;
127
+ /** The failure of a read still awaited as the page closes. */
128
+ readonly gone: (operation: string) => BrowserError;
129
+ /** The page's scope, which shared reads run in. */
130
+ readonly scope: Scope.Scope;
131
+ }) => {
132
+ const { now, actionTimeout } = options;
133
+ let state = initial;
134
+ let tickets = 0;
135
+ const admissions = new Map<number, Deferred.Deferred<void>>();
136
+ // Each shared read's sweep, which a write runs as it asks.
137
+ const sweeps: Array<() => void> = [];
138
+
139
+ const apply = (input: Input) => {
140
+ const next = step(state, input);
141
+
142
+ state = next.state;
143
+ if (input._tag === "Leave") admissions.delete(input.ticket.id);
144
+ for (const ticket of next.admitted) {
145
+ const admitted = admissions.get(ticket.id);
146
+
147
+ admissions.delete(ticket.id);
148
+ if (admitted !== undefined) Deferred.doneUnsafe(admitted, Exit.void);
149
+ }
150
+ if (input._tag === "Ask" && input.ticket.kind === "write") for (const sweep of sweeps) sweep();
151
+ };
152
+
153
+ const busy = (operation: string, askedAt: number, id?: number) =>
154
+ new BrowserError({
155
+ operation,
156
+ reason: new Busy({ waitedMillis: Math.round(now() - askedAt), ahead: ahead(state, id) }),
157
+ dispatched: false,
158
+ });
159
+
160
+ /**
161
+ * Run `effect` in a turn of `kind`, waiting at most `wait` for it, or failing `Busy`. The turn
162
+ * ends with `effect`, however it ends.
163
+ */
164
+ const turn =
165
+ (kind: Kind, operation: string, wait: Duration.Duration) =>
166
+ <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | BrowserError, R> =>
167
+ Effect.uninterruptibleMask((restore) =>
168
+ Effect.gen(function* () {
169
+ const ticket: Ticket = { id: ++tickets, kind };
170
+ const askedAt = now();
171
+
172
+ if ((yield* FailFast) && !admitsNow(state, kind)) return yield* busy(operation, askedAt);
173
+ const admitted = Deferred.makeUnsafe<void>();
174
+
175
+ admissions.set(ticket.id, admitted);
176
+ apply({ _tag: "Ask", ticket });
177
+
178
+ const given = yield* Effect.exit(
179
+ restore(
180
+ Deferred.await(admitted).pipe(
181
+ Effect.timeoutOrElse({
182
+ duration: wait,
183
+ orElse: () =>
184
+ Deferred.isDoneUnsafe(admitted)
185
+ ? Effect.void
186
+ : Effect.fail(busy(operation, askedAt, ticket.id)),
187
+ }),
188
+ ),
189
+ ),
190
+ );
191
+
192
+ if (Exit.isFailure(given)) {
193
+ apply({ _tag: "Leave", ticket });
194
+
195
+ return yield* Effect.failCause(given.cause);
196
+ }
197
+
198
+ return yield* restore(effect).pipe(
199
+ Effect.ensuring(Effect.sync(() => apply({ _tag: "Leave", ticket }))),
200
+ );
201
+ }),
202
+ );
203
+
204
+ /**
205
+ * A read whose identical calls share their work: `work` runs once per epoch and document while
206
+ * calls await it, and with `keep` its result serves the next call after all of them gave up,
207
+ * within an action timeout of its end. Pictures, whose callers say how old they may be, only
208
+ * join.
209
+ */
210
+ const shared = <A>(operation: string, keep: boolean) => {
211
+ const flights = new Map<string, Flight<A>>();
212
+ const kept = new Map<string, { readonly value: A; readonly until: number }>();
213
+
214
+ sweeps.push(() => {
215
+ kept.clear();
216
+ for (const flight of flights.values())
217
+ if (flight.waiting === 0) flight.fiber?.interruptUnsafe();
218
+ });
219
+
220
+ // A read in flight, its first caller waiting. However its fiber ends, even before it began,
221
+ // as when the page closes, its callers hear.
222
+ const start = (id: string, work: Effect.Effect<A, BrowserError>) =>
223
+ Effect.gen(function* () {
224
+ const flight: Flight<A> = {
225
+ epoch: state.epoch,
226
+ result: Deferred.makeUnsafe(),
227
+ waiting: 1,
228
+ fiber: undefined,
229
+ };
230
+
231
+ flights.set(id, flight);
232
+ flight.fiber = yield* turn(
233
+ "read",
234
+ operation,
235
+ actionTimeout,
236
+ )(work).pipe(
237
+ // The caller chose whether to wait; the work it started serves others too.
238
+ Effect.provideService(FailFast, false),
239
+ Effect.forkIn(options.scope),
240
+ );
241
+ flight.fiber.addObserver((exit) => {
242
+ flights.delete(id);
243
+ if (keep && Exit.isSuccess(exit) && flight.waiting === 0 && flight.epoch === state.epoch)
244
+ kept.set(id, { value: exit.value, until: now() + Duration.toMillis(actionTimeout) });
245
+ Deferred.doneUnsafe(
246
+ flight.result,
247
+ Exit.hasInterrupts(exit) ? Effect.fail(options.gone(operation)) : exit,
248
+ );
249
+ });
250
+
251
+ return flight;
252
+ });
253
+
254
+ const join = (id: string, work: Effect.Effect<A, BrowserError>) =>
255
+ Effect.acquireUseRelease(
256
+ Effect.suspend(() => {
257
+ const flying = flights.get(id);
258
+
259
+ if (flying === undefined) return start(id, work);
260
+ flying.waiting += 1;
261
+
262
+ return Effect.as(Effect.annotateCurrentSpan("shared", "joined"), flying);
263
+ }),
264
+ (flight) => Deferred.await(flight.result),
265
+ (flight) =>
266
+ Effect.sync(() => {
267
+ flight.waiting -= 1;
268
+ if (flight.waiting === 0 && flight.epoch < state.epoch) flight.fiber?.interruptUnsafe();
269
+ }),
270
+ );
271
+
272
+ return (key: string, work: Effect.Effect<A, BrowserError>): Effect.Effect<A, BrowserError> =>
273
+ Effect.gen(function* () {
274
+ const id = `${state.epoch} ${options.documentAt()} ${key}`;
275
+ const at = now();
276
+
277
+ for (const [old, { until }] of kept) if (until < at) kept.delete(old);
278
+ const hit = kept.get(id);
279
+
280
+ if (hit !== undefined) {
281
+ kept.delete(id);
282
+ yield* Effect.annotateCurrentSpan("shared", "kept");
283
+
284
+ return hit.value;
285
+ }
286
+ if ((yield* FailFast) && !admitsNow(state, "read")) return yield* busy(operation, at);
287
+
288
+ return yield* join(id, work);
289
+ });
290
+ };
291
+
292
+ return {
293
+ /** A turn alone, for an action: `wait` bounds the wait for it. */
294
+ write: (operation: string, wait: Duration.Duration) => turn("write", operation, wait),
295
+ /** A turn beside other reads, waited for within the action timeout. */
296
+ read: (operation: string) => turn("read", operation, actionTimeout),
297
+ shared,
298
+ };
299
+ };
300
+
301
+ export type Lane = ReturnType<typeof make>;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Moving a page between documents: going to an address, back through its history, or reloading.
3
+ * Each move is an action, so it takes the page's turn, goes through the input guard and is
4
+ * recorded, but it sends no input.
5
+ */
6
+ import { Deferred, Duration, Effect, Schedule } from "effect";
7
+
8
+ import {
9
+ BrowserError,
10
+ InvalidRequest,
11
+ NavigationFailed,
12
+ NotActionable,
13
+ NotFound,
14
+ } from "../../BrowserError.ts";
15
+ import type { Guard } from "../input/guard.ts";
16
+ import type { InputMarks, Perform } from "../input/perform.ts";
17
+ import { failWith, type PageContext } from "./context.ts";
18
+ import * as Url from "./url.ts";
19
+
20
+ export const make = (
21
+ page: PageContext,
22
+ perform: Perform,
23
+ preparePolicy: Guard["preparePolicy"],
24
+ ) => {
25
+ const { playwright, settings, native } = page;
26
+ const { send } = page.protocol;
27
+
28
+ // `address` is where `run` goes, as the action, the guard and a failure report it.
29
+ const navigation = (name: string, run: () => Promise<unknown>, address: string) => {
30
+ const url = Url.redact(address);
31
+
32
+ return perform(
33
+ name,
34
+ { target: url, input: false },
35
+ settings.navigationTimeout,
36
+ preparePolicy(name, { target: url }, [], { destination: url }),
37
+ (marks) =>
38
+ marks.sent.pipe(
39
+ Effect.andThen(native(name, run)),
40
+ Effect.mapError((error) =>
41
+ error.reason._tag === "Failed" &&
42
+ /net::|NS_ERROR|Cannot navigate/i.test(error.reason.detail)
43
+ ? new BrowserError({
44
+ operation: name,
45
+ reason: new NavigationFailed({ url, detail: error.reason.detail }),
46
+ dispatched: true,
47
+ })
48
+ : error,
49
+ ),
50
+ Effect.asVoid,
51
+ ),
52
+ );
53
+ };
54
+
55
+ const history = native("back", () => send("Page.getNavigationHistory"));
56
+
57
+ // While a traversal swaps documents, Chromium can briefly route this session to the outgoing
58
+ // document, which has become inactive (for instance after it entered the back-forward cache),
59
+ // so a read during the traversal is retried until the session reaches the active document.
60
+ const traversingHistory = history.pipe(
61
+ Effect.retry({
62
+ schedule: Schedule.spaced(Duration.millis(50)),
63
+ while: (error) =>
64
+ error.reason._tag === "Failed" &&
65
+ /not attached to an active page/i.test(error.reason.detail),
66
+ }),
67
+ );
68
+
69
+ // The current entry's title is the one the page last set, and empty where it set none, so no
70
+ // address stands in for it. The browser answers it, whatever the page's script is doing.
71
+ const title = native("title", () => send("Page.getNavigationHistory")).pipe(
72
+ Effect.map(({ entries, currentIndex }) => entries[currentIndex]?.title ?? ""),
73
+ );
74
+
75
+ const noPrevious = failWith("back", new NotFound({ target: "a previous page in this tab" }));
76
+
77
+ const prepareBack = Effect.gen(function* () {
78
+ const before = yield* history;
79
+ const current = before.entries[before.currentIndex];
80
+ const previous = before.entries[before.currentIndex - 1];
81
+
82
+ if (previous === undefined) return yield* noPrevious;
83
+ const plan = yield* preparePolicy("back", {}, [], { destination: Url.redact(previous.url) });
84
+
85
+ return {
86
+ request: plan.request,
87
+ validate: Effect.gen(function* () {
88
+ const approval = yield* plan.validate;
89
+ const latest = yield* history;
90
+
91
+ if (
92
+ latest.currentIndex !== before.currentIndex ||
93
+ latest.entries[latest.currentIndex]?.id !== current?.id ||
94
+ latest.entries[latest.currentIndex - 1]?.id !== previous.id ||
95
+ latest.entries[latest.currentIndex - 1]?.url !== previous.url
96
+ )
97
+ return yield* failWith(
98
+ "back",
99
+ new NotActionable({
100
+ detail: "the navigation history changed while input policy was pending",
101
+ }),
102
+ );
103
+
104
+ return approval;
105
+ }),
106
+ };
107
+ });
108
+
109
+ // One protocol traversal on every path. It has committed once the tab leaves its entry, which
110
+ // also covers entries that only an iframe created, where the main frame never navigates, and
111
+ // entries sharing a URL. A frame navigation then shows Playwright has caught up before the
112
+ // main document's readiness is read.
113
+ const goBack = (marks: InputMarks) =>
114
+ Effect.gen(function* () {
115
+ const before = yield* history;
116
+ const current = before.entries[before.currentIndex];
117
+ const previous = before.entries[before.currentIndex - 1];
118
+
119
+ if (current === undefined || previous === undefined) return yield* noPrevious;
120
+ const navigated = yield* Deferred.make<void>();
121
+
122
+ const onNavigation = () => {
123
+ Deferred.doneUnsafe(navigated, Effect.void);
124
+ };
125
+
126
+ yield* Effect.acquireUseRelease(
127
+ Effect.sync(() => playwright.on("framenavigated", onNavigation)),
128
+ () =>
129
+ Effect.gen(function* () {
130
+ yield* marks.sent;
131
+ yield* native("back", () =>
132
+ send("Page.navigateToHistoryEntry", { entryId: previous.id }),
133
+ );
134
+ yield* traversingHistory.pipe(
135
+ Effect.repeat({
136
+ schedule: Schedule.spaced(Duration.millis(50)),
137
+ until: (latest) => latest.entries[latest.currentIndex]?.id !== current.id,
138
+ }),
139
+ );
140
+ yield* Deferred.await(navigated);
141
+ }),
142
+ () => Effect.sync(() => playwright.off("framenavigated", onNavigation)),
143
+ );
144
+ yield* native("back", () => playwright.waitForLoadState("domcontentloaded", { timeout: 0 }));
145
+ });
146
+
147
+ const goto = (url: string) => {
148
+ const parsed = Url.parse(url);
149
+
150
+ if (
151
+ parsed === null ||
152
+ !["http:", "https:", "about:", "data:", "file:"].includes(parsed.protocol)
153
+ )
154
+ return failWith(
155
+ "navigate",
156
+ new InvalidRequest({ detail: `"${Url.redact(url)}" is not a URL` }),
157
+ );
158
+
159
+ return navigation(
160
+ "navigate",
161
+ () => playwright.goto(parsed.href, { waitUntil: "domcontentloaded", timeout: 0 }),
162
+ parsed.href,
163
+ );
164
+ };
165
+
166
+ const back = perform(
167
+ "back",
168
+ { target: "back", input: false },
169
+ settings.navigationTimeout,
170
+ prepareBack,
171
+ goBack,
172
+ );
173
+
174
+ const reload = Effect.suspend(() =>
175
+ navigation(
176
+ "reload",
177
+ () => playwright.reload({ waitUntil: "domcontentloaded", timeout: 0 }),
178
+ playwright.url(),
179
+ ),
180
+ );
181
+
182
+ return { goto, back, reload, title };
183
+ };
@@ -0,0 +1,87 @@
1
+ /** A page's input waits for the actual tabs it opened to finish the browser's registration. */
2
+ import { Deferred, Effect, Exit } from "effect";
3
+ import type { CDPSession, Page as PlaywrightPage } from "playwright-core";
4
+
5
+ import { BrowserError, Closed, Failed, type Reason } from "../../BrowserError.ts";
6
+
7
+ /** How the browser's registration of each tab it was told of ended, once it has. */
8
+ export type Registrations = WeakMap<PlaywrightPage, Deferred.Deferred<void, BrowserError>>;
9
+
10
+ export interface Mark {
11
+ windows: number;
12
+ readonly registrations: Array<Effect.Effect<void, BrowserError>>;
13
+ changed: Deferred.Deferred<void>;
14
+ }
15
+
16
+ export const make = Effect.fnUntraced(function* (
17
+ playwright: PlaywrightPage,
18
+ cdp: CDPSession,
19
+ registrations: Registrations,
20
+ closedBy: () => Closed["cause"],
21
+ ) {
22
+ const failed = (reason: Reason) =>
23
+ Exit.fail(new BrowserError({ operation: "newPage", reason, dispatched: false }));
24
+
25
+ let active: Mark | undefined;
26
+ const gone = Deferred.makeUnsafe<never, BrowserError>();
27
+
28
+ const windowOpened = () => {
29
+ if (active !== undefined) active.windows += 1;
30
+ };
31
+
32
+ const popup = (opened: PlaywrightPage) => {
33
+ if (active === undefined) return;
34
+ // Playwright emits the context's page event first, so its owner already has this exact
35
+ // popup's registration. A window notification alone has no target id and cannot name it.
36
+ const registered = registrations.get(opened);
37
+
38
+ active.registrations.push(
39
+ registered === undefined
40
+ ? failed(new Failed({ detail: "the popup has no registration" }))
41
+ : Deferred.await(registered),
42
+ );
43
+ Deferred.doneUnsafe(active.changed, Exit.void);
44
+ active.changed = Deferred.makeUnsafe<void>();
45
+ };
46
+
47
+ const clear = Effect.sync(() => {
48
+ active = undefined;
49
+ });
50
+
51
+ yield* Effect.acquireRelease(
52
+ Effect.sync(() => {
53
+ cdp.on("Page.windowOpen", windowOpened);
54
+ playwright.on("popup", popup);
55
+ }),
56
+ () =>
57
+ Effect.sync(() => {
58
+ active = undefined;
59
+ cdp.off("Page.windowOpen", windowOpened);
60
+ playwright.off("popup", popup);
61
+ Deferred.doneUnsafe(gone, failed(new Closed({ cause: closedBy() })));
62
+ }),
63
+ );
64
+
65
+ return {
66
+ mark: () => {
67
+ const mark: Mark = { windows: 0, registrations: [], changed: Deferred.makeUnsafe<void>() };
68
+
69
+ active = mark;
70
+
71
+ return mark;
72
+ },
73
+ clear,
74
+ settle: (mark: Mark) =>
75
+ Effect.gen(function* () {
76
+ // A blocked window or a tab that closes as it opens may never become a Playwright popup.
77
+ // Discovery and registration both share the input's deadline, so an unresolved opening
78
+ // fails with potentially dispatched input rather than claiming the tab has been tracked.
79
+ while (mark.registrations.length < mark.windows) yield* Deferred.await(mark.changed);
80
+ // A real popup has a known registration; its failure stays a failure, and its wait shares
81
+ // the input's deadline rather than quietly returning before the browser tracks it.
82
+ yield* Effect.forEach(mark.registrations, (registration) => registration);
83
+ }).pipe(Effect.raceFirst(Deferred.await(gone)), Effect.ensuring(clear)),
84
+ };
85
+ });
86
+
87
+ export type Openings = Effect.Success<ReturnType<typeof make>>;