effect-browser 0.2.0-beta.9 → 0.3.0-beta.11
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.
- package/README.md +861 -1161
- package/dist/Browser-B4qncxe7.mjs +628 -0
- package/dist/Browser-B4qncxe7.mjs.map +1 -0
- package/dist/Browser.d.mts +129 -3
- package/dist/Browser.mjs +2 -22
- package/dist/BrowserError-Coy7EW1N.d.mts +140 -0
- package/dist/BrowserError.d.mts +2 -0
- package/dist/BrowserError.mjs +211 -0
- package/dist/BrowserError.mjs.map +1 -0
- package/dist/BrowserEvent-BRu2juEz.d.mts +279 -0
- package/dist/BrowserEvent.d.mts +2 -0
- package/dist/BrowserEvent.mjs +297 -0
- package/dist/BrowserEvent.mjs.map +1 -0
- package/dist/Cdp.d.mts +32 -0
- package/dist/Cdp.mjs +61 -0
- package/dist/Cdp.mjs.map +1 -0
- package/dist/Change-DNvztNLu.mjs +138 -0
- package/dist/Change-DNvztNLu.mjs.map +1 -0
- package/dist/Change-iMlcuzVD.d.mts +71 -0
- package/dist/Change.d.mts +2 -0
- package/dist/Change.mjs +2 -0
- package/dist/Chromium.d.mts +29 -81
- package/dist/Chromium.mjs +57 -392
- package/dist/Chromium.mjs.map +1 -1
- package/dist/Expect.d.mts +62 -0
- package/dist/Expect.mjs +114 -0
- package/dist/Expect.mjs.map +1 -0
- package/dist/Frame-Di2PxyF7.d.mts +119 -0
- package/dist/Frame.d.mts +2 -0
- package/dist/Frame.mjs +125 -0
- package/dist/Frame.mjs.map +1 -0
- package/dist/Moment.d.mts +2 -0
- package/dist/Moment.mjs +288 -0
- package/dist/Moment.mjs.map +1 -0
- package/dist/Motion.d.mts +40 -0
- package/dist/Motion.mjs +122 -0
- package/dist/Motion.mjs.map +1 -0
- package/dist/Page-B5cjaUp5.mjs +618 -0
- package/dist/Page-B5cjaUp5.mjs.map +1 -0
- package/dist/Page-B7X6DQUn.d.mts +595 -0
- package/dist/Page.d.mts +2 -0
- package/dist/Page.mjs +2 -0
- package/dist/Plan-Akcac63g.d.mts +209 -0
- package/dist/Plan-D1wrJVs8.mjs +508 -0
- package/dist/Plan-D1wrJVs8.mjs.map +1 -0
- package/dist/Plan.d.mts +2 -3
- package/dist/Plan.mjs +2 -2
- package/dist/Policy.d.mts +76 -0
- package/dist/Policy.mjs +237 -0
- package/dist/Policy.mjs.map +1 -0
- package/dist/Presentation.d.mts +48 -0
- package/dist/Presentation.mjs +117 -0
- package/dist/Presentation.mjs.map +1 -0
- package/dist/Snapshot-B34hZ4Jl.d.mts +68 -0
- package/dist/Snapshot.d.mts +2 -0
- package/dist/Snapshot.mjs +79 -0
- package/dist/Snapshot.mjs.map +1 -0
- package/dist/Stage.d.mts +64 -0
- package/dist/Stage.mjs +237 -0
- package/dist/Stage.mjs.map +1 -0
- package/dist/Supervisor-CYWFK3vM.d.mts +280 -0
- package/dist/Supervisor-TNKNOVzu.mjs +599 -0
- package/dist/Supervisor-TNKNOVzu.mjs.map +1 -0
- package/dist/Supervisor.d.mts +2 -0
- package/dist/Supervisor.mjs +2 -0
- package/dist/context-B6xr8nVi.mjs +261 -0
- package/dist/context-B6xr8nVi.mjs.map +1 -0
- package/dist/index.d.mts +17 -14
- package/dist/index.mjs +18 -13
- package/dist/match.inpage-xOe9LNO0.mjs +48 -0
- package/dist/match.inpage-xOe9LNO0.mjs.map +1 -0
- package/dist/page-BAf__WKX.mjs +5075 -0
- package/dist/page-BAf__WKX.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/url-Cd04Okid.mjs +264 -0
- package/dist/url-Cd04Okid.mjs.map +1 -0
- package/package.json +96 -61
- package/src/Browser.ts +672 -0
- package/src/BrowserError.ts +234 -0
- package/src/BrowserEvent.ts +296 -0
- package/src/Cdp.ts +101 -0
- package/src/Change.ts +88 -0
- package/src/Chromium.ts +102 -0
- package/src/Expect.ts +180 -0
- package/src/Frame.ts +132 -0
- package/src/Moment.ts +479 -0
- package/src/Motion.ts +166 -0
- package/src/Page.ts +522 -0
- package/src/Plan.ts +663 -0
- package/src/Policy.ts +325 -0
- package/src/Presentation.ts +195 -0
- package/src/Snapshot.ts +89 -0
- package/src/Stage.ts +467 -0
- package/src/Supervisor.ts +599 -0
- package/src/index.ts +20 -0
- package/src/internal/input/actions.ts +610 -0
- package/src/internal/input/dispatch.ts +161 -0
- package/src/internal/input/edit.inpage.ts +162 -0
- package/src/internal/input/evidence.inpage.ts +164 -0
- package/src/internal/input/gate.ts +224 -0
- package/src/internal/input/guard.inpage.ts +605 -0
- package/src/internal/input/guard.ts +184 -0
- package/src/internal/input/keyboard.ts +112 -0
- package/src/internal/input/keys.ts +127 -0
- package/src/internal/input/perform.ts +376 -0
- package/src/internal/input/pointer.ts +153 -0
- package/src/internal/input/replies.ts +195 -0
- package/src/internal/input/settle.ts +50 -0
- package/src/internal/input/style.ts +186 -0
- package/src/internal/input/targets.inpage.ts +293 -0
- package/src/internal/input/targets.ts +148 -0
- package/src/internal/page/bridge.ts +391 -0
- package/src/internal/page/context.ts +314 -0
- package/src/internal/page/identity.ts +35 -0
- package/src/internal/page/lane.ts +311 -0
- package/src/internal/page/navigation.ts +183 -0
- package/src/internal/page/openings.ts +87 -0
- package/src/internal/page/page.ts +165 -0
- package/src/internal/page/url.ts +100 -0
- package/src/internal/page/viewport.ts +43 -0
- package/src/internal/pictures/capture.ts +776 -0
- package/src/internal/pictures/clock.ts +141 -0
- package/src/internal/pictures/counts.ts +92 -0
- package/src/internal/pictures/pictures.ts +521 -0
- package/src/internal/pictures/transport.ts +243 -0
- package/src/internal/reading/choose.ts +100 -0
- package/src/internal/reading/context.inpage.ts +201 -0
- package/src/internal/reading/controls.inpage.ts +158 -0
- package/src/internal/reading/expect.inpage.ts +70 -0
- package/src/internal/reading/match.inpage.ts +92 -0
- package/src/internal/reading/names.inpage.ts +306 -0
- package/src/internal/reading/outline.inpage.ts +260 -0
- package/src/internal/reading/reading.ts +223 -0
- package/src/internal/reading/ready.inpage.ts +164 -0
- package/src/internal/reading/ready.ts +111 -0
- package/src/internal/reading/subjects.inpage.ts +188 -0
- package/src/internal/reading/text.inpage.ts +135 -0
- package/src/internal/reading/walk.inpage.ts +220 -0
- package/src/internal/supervisor/lifecycle.ts +416 -0
- package/src/internal/timeline/changes.inpage.ts +216 -0
- package/src/internal/timeline/changes.ts +100 -0
- package/src/internal/timeline/events.ts +94 -0
- package/src/internal/timeline/fold.inpage.ts +123 -0
- package/src/internal/timeline/history.inpage.ts +229 -0
- package/src/internal/timeline/marks.inpage.ts +207 -0
- package/src/internal/timeline/record.inpage.ts +133 -0
- package/src/internal/timeline/sight.inpage.ts +122 -0
- package/src/internal/timeline/window.ts +96 -0
- package/src/internal/timeline/words.ts +65 -0
- package/dist/Bootstrap-B9UUXsvF.d.mts +0 -183
- package/dist/Bootstrap-CeQOWBy6.mjs +0 -189
- package/dist/Bootstrap-CeQOWBy6.mjs.map +0 -1
- package/dist/Bootstrap.d.mts +0 -3
- package/dist/Bootstrap.mjs +0 -2
- package/dist/Browser-BF9_sORP.d.mts +0 -388
- package/dist/Browser.mjs.map +0 -1
- package/dist/BrowserData-CPIIIHol.d.mts +0 -585
- package/dist/BrowserData.d.mts +0 -3
- package/dist/BrowserData.mjs +0 -695
- package/dist/BrowserData.mjs.map +0 -1
- package/dist/BrowserRuntime-B6nT1G4X.d.mts +0 -148
- package/dist/BrowserRuntime-C1r2aSRK.mjs +0 -6820
- package/dist/BrowserRuntime-C1r2aSRK.mjs.map +0 -1
- package/dist/BrowserRuntime.d.mts +0 -3
- package/dist/BrowserRuntime.mjs +0 -3
- package/dist/Capture.d.mts +0 -56
- package/dist/Capture.mjs +0 -678
- package/dist/Capture.mjs.map +0 -1
- package/dist/CaptureData-CYriZHX1.mjs +0 -201
- package/dist/CaptureData-CYriZHX1.mjs.map +0 -1
- package/dist/CaptureData.d.mts +0 -219
- package/dist/CaptureData.mjs +0 -2
- package/dist/Errors.d.mts +0 -158
- package/dist/Errors.mjs +0 -207
- package/dist/Errors.mjs.map +0 -1
- package/dist/Images-yCG9BmRU.mjs +0 -76
- package/dist/Images-yCG9BmRU.mjs.map +0 -1
- package/dist/OperationOptions-DAeRS6O9.mjs +0 -55
- package/dist/OperationOptions-DAeRS6O9.mjs.map +0 -1
- package/dist/PageControl.d.mts +0 -15
- package/dist/PageControl.mjs +0 -34
- package/dist/PageControl.mjs.map +0 -1
- package/dist/PageRegistry-B3q9DS3g.mjs +0 -164
- package/dist/PageRegistry-B3q9DS3g.mjs.map +0 -1
- package/dist/Plan-Dyxu2q8C.mjs +0 -543
- package/dist/Plan-Dyxu2q8C.mjs.map +0 -1
- package/dist/PlanData-B668RxbQ.d.mts +0 -4831
- package/dist/PlanData.d.mts +0 -3
- package/dist/PlanData.mjs +0 -268
- package/dist/PlanData.mjs.map +0 -1
- package/dist/PlanInput-BJcPPncs.mjs +0 -75
- package/dist/PlanInput-BJcPPncs.mjs.map +0 -1
- package/dist/PublicSession-DCSMMqOj.mjs +0 -5602
- package/dist/PublicSession-DCSMMqOj.mjs.map +0 -1
- package/dist/Testing.d.mts +0 -479
- package/dist/Testing.mjs +0 -2147
- package/dist/Testing.mjs.map +0 -1
- package/dist/Timeline-fV22Xk-S.d.mts +0 -29
- package/dist/Timeline.d.mts +0 -4
- package/dist/Timeline.mjs +0 -20
- package/dist/Timeline.mjs.map +0 -1
- package/dist/TimelineData-BsOn-YGZ.d.mts +0 -3818
- package/dist/TimelineData.d.mts +0 -3
- package/dist/TimelineData.mjs +0 -482
- package/dist/TimelineData.mjs.map +0 -1
|
@@ -0,0 +1,314 @@
|
|
|
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 { Gate } from "../input/gate.ts";
|
|
24
|
+
import { Current } from "../input/replies.ts";
|
|
25
|
+
import type * as BrowserClock from "../pictures/clock.ts";
|
|
26
|
+
import * as Lane from "./lane.ts";
|
|
27
|
+
import type { Openings } from "./openings.ts";
|
|
28
|
+
import * as Url from "./url.ts";
|
|
29
|
+
|
|
30
|
+
/** Why a page is gone. */
|
|
31
|
+
export type ClosedCause = Closed["cause"];
|
|
32
|
+
|
|
33
|
+
/** What every page of a browser shares, from its options. */
|
|
34
|
+
export interface Settings {
|
|
35
|
+
readonly actionTimeout: Duration.Duration;
|
|
36
|
+
readonly policyTimeout: Duration.Duration;
|
|
37
|
+
readonly navigationTimeout: Duration.Duration;
|
|
38
|
+
/** How long screencast frames stay in `recentFrames`, measured back from the newest. */
|
|
39
|
+
readonly frameHistory: Duration.Duration;
|
|
40
|
+
readonly guard: InputGuard | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MakeOptions {
|
|
44
|
+
/** The page's CDP target id, which is also its main frame's id. */
|
|
45
|
+
readonly id: string;
|
|
46
|
+
/** The id of the browser's session, which its frames carry. */
|
|
47
|
+
readonly session: string;
|
|
48
|
+
/** This tracking connection, distinct from the provider session and capture socket. */
|
|
49
|
+
readonly connection: string;
|
|
50
|
+
/** The page's address when the browser began tracking it. */
|
|
51
|
+
readonly url: string;
|
|
52
|
+
readonly playwright: PlaywrightPage;
|
|
53
|
+
readonly cdp: CDPSession;
|
|
54
|
+
/** A call's reply, or a failure as for a closed page once the browser is lost first. */
|
|
55
|
+
readonly untilLost: <A>(reply: Promise<A>) => Promise<A>;
|
|
56
|
+
readonly settings: Settings;
|
|
57
|
+
readonly clock: Clock.Clock;
|
|
58
|
+
/** Browser-wide input authority; direct internal page fixtures need not provide one. */
|
|
59
|
+
readonly inputGate?: Gate | undefined;
|
|
60
|
+
/** The browser's epoch mapping: its first capture measures it, and later ones renew it. */
|
|
61
|
+
readonly mapping: BrowserClock.Mapping;
|
|
62
|
+
readonly publish: (event: BrowserEvent) => number;
|
|
63
|
+
/** This page's retained events, oldest first. */
|
|
64
|
+
readonly recentEvents: Effect.Effect<ReadonlyArray<BrowserEvent>>;
|
|
65
|
+
/** Registration of the actual popups this page's current input opened. */
|
|
66
|
+
readonly openings: Openings;
|
|
67
|
+
/** Succeeds once the page's own session holds focus emulation, which keeps it painting behind. */
|
|
68
|
+
readonly focused: Effect.Effect<void, BrowserError>;
|
|
69
|
+
/** Succeeds once the page's own session has the Page domain on, so it sees every later commit. */
|
|
70
|
+
readonly paging: (operation: string) => Effect.Effect<void, BrowserError>;
|
|
71
|
+
/** Why the page is gone, once it is: its browser's loss, else its own close or crash. */
|
|
72
|
+
readonly closedBy: () => ClosedCause;
|
|
73
|
+
/** Where the page's screencast runs, if not on its own session. */
|
|
74
|
+
readonly capture?: CaptureSource | undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const messageOf = (cause: unknown): string =>
|
|
78
|
+
cause instanceof Error ? cause.message : typeof cause === "string" ? cause : "unknown error";
|
|
79
|
+
|
|
80
|
+
const closedPattern =
|
|
81
|
+
/has been closed|Target closed|Session closed|browser has disconnected|Target page, context or browser/i;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Map a Playwright or protocol failure to a reason, `Closed` with `closed` as its cause for a page
|
|
85
|
+
* or browser that is gone. A call that gives Playwright a timeout passes the same bound, so its
|
|
86
|
+
* `Timeout` reports it; without one, Playwright's own message stays.
|
|
87
|
+
*/
|
|
88
|
+
export const reasonOf = (
|
|
89
|
+
cause: unknown,
|
|
90
|
+
timeoutMillis?: number,
|
|
91
|
+
closed: ClosedCause = "connection",
|
|
92
|
+
): Reason => {
|
|
93
|
+
const message = messageOf(cause);
|
|
94
|
+
|
|
95
|
+
if (closedPattern.test(message)) return new Closed({ cause: closed });
|
|
96
|
+
if (cause instanceof Error && cause.name === "TimeoutError" && timeoutMillis !== undefined)
|
|
97
|
+
return new Timeout({ millis: timeoutMillis });
|
|
98
|
+
const line = message.split("\n")[0] ?? message;
|
|
99
|
+
|
|
100
|
+
return new Failed({ detail: Url.redactWithin(line.replace(/^[\w.]+: /, "")) });
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* One Playwright or protocol call, failing undispatched. A closed page says why it is gone as the
|
|
105
|
+
* browser knows then: the library's own calls fail with the browser's loss, and Playwright fails
|
|
106
|
+
* its calls on a dropped connection after reporting the context closed.
|
|
107
|
+
*/
|
|
108
|
+
export const call = <A>(
|
|
109
|
+
operation: string,
|
|
110
|
+
run: () => Promise<A>,
|
|
111
|
+
closedBy: () => ClosedCause,
|
|
112
|
+
timeoutMillis?: number,
|
|
113
|
+
) =>
|
|
114
|
+
Effect.tryPromise({
|
|
115
|
+
try: run,
|
|
116
|
+
catch: (cause) => undispatched(operation, reasonOf(cause, timeoutMillis, closedBy())),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
export const contextGone = (error: BrowserError) =>
|
|
120
|
+
error.reason._tag === "Failed" &&
|
|
121
|
+
/Cannot find context|Execution context was destroyed|__effectBrowser|Inspected target navigated/i.test(
|
|
122
|
+
error.reason.detail,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What a page operation spent on the page's own protocol session: its calls, the bytes of their
|
|
127
|
+
* parameters and results, and how long at least one of them awaited its reply. An operation inside
|
|
128
|
+
* another counts toward both.
|
|
129
|
+
*/
|
|
130
|
+
interface Cost {
|
|
131
|
+
readonly enclosing: Cost | undefined;
|
|
132
|
+
calls: number;
|
|
133
|
+
bytesOut: number;
|
|
134
|
+
bytesIn: number;
|
|
135
|
+
waitedMillis: number;
|
|
136
|
+
awaiting: number;
|
|
137
|
+
awaitingSince: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The cost of the page operation the current fiber runs, if any. */
|
|
141
|
+
const CurrentCost = Context.Reference<Cost | undefined>(
|
|
142
|
+
"effect-browser/internal/page/CurrentCost",
|
|
143
|
+
{
|
|
144
|
+
defaultValue: () => undefined,
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
/** Track the promise returned to the action, after tracing wraps it, so input counts once. */
|
|
149
|
+
const track = <A>(reply: Promise<A>) => Fiber.getCurrent()?.getRef(Current)?.track(reply) ?? reply;
|
|
150
|
+
|
|
151
|
+
// A message's size on the wire, give or take its envelope.
|
|
152
|
+
const sizeOf = (value: unknown) =>
|
|
153
|
+
value === undefined ? 0 : Buffer.byteLength(JSON.stringify(value));
|
|
154
|
+
|
|
155
|
+
/** A failure of `operation` before any of its input reached the browser. */
|
|
156
|
+
export const undispatched = (operation: string, reason: Reason) =>
|
|
157
|
+
new BrowserError({ operation, reason, dispatched: false });
|
|
158
|
+
|
|
159
|
+
export const failWith = (operation: string, reason: Reason) =>
|
|
160
|
+
Effect.fail(undispatched(operation, reason));
|
|
161
|
+
|
|
162
|
+
export const decodeWith =
|
|
163
|
+
<A>(operation: string, schema: Schema.Codec<A, unknown>) =>
|
|
164
|
+
(value: unknown) =>
|
|
165
|
+
Schema.decodeUnknownEffect(schema)(value).pipe(
|
|
166
|
+
Effect.mapError((error) => undispatched(operation, new Failed({ detail: error.message }))),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
export const make = (options: MakeOptions, scope: Scope.Scope) => {
|
|
170
|
+
const { id, clock } = options;
|
|
171
|
+
const now = () => Number(clock.monotonicTimeNanosUnsafe()) / 1e6;
|
|
172
|
+
// Deadlines and pacing guard the page's lane, so page operations run on the owner's clock; a
|
|
173
|
+
// caller's clock, such as a TestClock, cannot stall or stretch them.
|
|
174
|
+
const owned = Effect.provideService(Clock.Clock, clock);
|
|
175
|
+
|
|
176
|
+
// This page's spans name it and report what the operation cost on the protocol. Fine-grained
|
|
177
|
+
// ones carry a level below the default Info, so an application keeps only the coarse ones by
|
|
178
|
+
// raising `Tracer.MinimumTraceLevel`.
|
|
179
|
+
const span =
|
|
180
|
+
(name: string, attributes: Record<string, unknown> = {}, level?: "Debug" | "Trace") =>
|
|
181
|
+
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
182
|
+
Effect.gen(function* () {
|
|
183
|
+
const cost: Cost = {
|
|
184
|
+
enclosing: yield* CurrentCost,
|
|
185
|
+
calls: 0,
|
|
186
|
+
bytesOut: 0,
|
|
187
|
+
bytesIn: 0,
|
|
188
|
+
waitedMillis: 0,
|
|
189
|
+
awaiting: 0,
|
|
190
|
+
awaitingSince: 0,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return yield* effect.pipe(
|
|
194
|
+
Effect.provideService(CurrentCost, cost),
|
|
195
|
+
Effect.ensuring(
|
|
196
|
+
Effect.suspend(() =>
|
|
197
|
+
Effect.annotateCurrentSpan({
|
|
198
|
+
calls: cost.calls,
|
|
199
|
+
bytesOut: cost.bytesOut,
|
|
200
|
+
bytesIn: cost.bytesIn,
|
|
201
|
+
waitedMillis: Math.round(cost.waitedMillis * 10) / 10,
|
|
202
|
+
}),
|
|
203
|
+
),
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
}).pipe(
|
|
207
|
+
Effect.withSpan(
|
|
208
|
+
name,
|
|
209
|
+
{ attributes: { page: id, ...attributes }, ...(level === undefined ? {} : { level }) },
|
|
210
|
+
{ captureStackTrace: false },
|
|
211
|
+
),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// Every call on the page's own protocol session, counted into the operation that sends it and
|
|
215
|
+
// the operations around that one. It is looked up when the call is sent, so input pipelined
|
|
216
|
+
// from inside an operation counts toward it too.
|
|
217
|
+
const send: CDPSession["send"] = (method, params) => {
|
|
218
|
+
const cost = Fiber.getCurrent()?.getRef(CurrentCost);
|
|
219
|
+
const sentAt = now();
|
|
220
|
+
const bytesOut = sizeOf(params);
|
|
221
|
+
|
|
222
|
+
for (let each = cost; each !== undefined; each = each.enclosing) {
|
|
223
|
+
each.calls++;
|
|
224
|
+
each.bytesOut += bytesOut;
|
|
225
|
+
if (each.awaiting++ === 0) each.awaitingSince = sentAt;
|
|
226
|
+
}
|
|
227
|
+
const reply = options.untilLost(options.cdp.send(method, params));
|
|
228
|
+
|
|
229
|
+
if (cost === undefined) return track(reply);
|
|
230
|
+
|
|
231
|
+
const settle = (bytesIn: number) => {
|
|
232
|
+
const at = now();
|
|
233
|
+
|
|
234
|
+
for (let each: Cost | undefined = cost; each !== undefined; each = each.enclosing) {
|
|
235
|
+
each.bytesIn += bytesIn;
|
|
236
|
+
if (--each.awaiting === 0) each.waitedMillis += at - each.awaitingSince;
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
return track(
|
|
241
|
+
reply.then(
|
|
242
|
+
(result) => {
|
|
243
|
+
settle(sizeOf(result));
|
|
244
|
+
|
|
245
|
+
return result;
|
|
246
|
+
},
|
|
247
|
+
(cause: unknown) => {
|
|
248
|
+
settle(0);
|
|
249
|
+
|
|
250
|
+
return Promise.reject(cause);
|
|
251
|
+
},
|
|
252
|
+
),
|
|
253
|
+
);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// Every protocol or Playwright call. Errors are undispatched here; `perform` marks them
|
|
257
|
+
// dispatched once input has gone out.
|
|
258
|
+
const native = <A>(operation: string, run: () => Promise<A>, timeoutMillis?: number) =>
|
|
259
|
+
call(operation, () => track(run()), options.closedBy, timeoutMillis);
|
|
260
|
+
|
|
261
|
+
/** Fail with `Timeout` once `duration` has passed, the action timeout unless another is given. */
|
|
262
|
+
const within =
|
|
263
|
+
(operation: string, duration: Duration.Input = options.settings.actionTimeout) =>
|
|
264
|
+
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
265
|
+
Effect.timeoutOrElse(effect, {
|
|
266
|
+
duration,
|
|
267
|
+
orElse: () => failWith(operation, new Timeout({ millis: Duration.toMillis(duration) })),
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// The latest submitted input or page change. A read that must follow it waits for the action in
|
|
271
|
+
// flight, so only paint from after its latest input is current. An action ends after its input
|
|
272
|
+
// was handled, so if that input changed the page, newer paint follows; a lost final paint is
|
|
273
|
+
// bounded by recency instead. Paint from before the current document began, when the page's own
|
|
274
|
+
// session saw the main frame commit it, shows a page the tab has left, so no read reuses it,
|
|
275
|
+
// whatever it asks.
|
|
276
|
+
const activity = { inputAt: 0, documentAt: Number.NEGATIVE_INFINITY };
|
|
277
|
+
|
|
278
|
+
const noteInput = () => {
|
|
279
|
+
activity.inputAt = Math.max(activity.inputAt, now());
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const lane = Lane.make({
|
|
283
|
+
now,
|
|
284
|
+
actionTimeout: options.settings.actionTimeout,
|
|
285
|
+
documentAt: () => activity.documentAt,
|
|
286
|
+
gone: (operation) => undispatched(operation, new Closed({ cause: options.closedBy() })),
|
|
287
|
+
scope,
|
|
288
|
+
inputGate: options.inputGate,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Where this page's latest move left its pointer, which is where its next plain glide starts and
|
|
292
|
+
// where a held button is released. Its lane orders the page's input, and Chromium keeps a
|
|
293
|
+
// pointer per page too.
|
|
294
|
+
const pointer = MutableRef.make(Option.none<Point>());
|
|
295
|
+
|
|
296
|
+
// Named, so that the declarations of what holds it need not spell out Playwright's protocol types.
|
|
297
|
+
const protocol: Pick<CDPSession, "send"> = { send };
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
...options,
|
|
301
|
+
lane,
|
|
302
|
+
pointer,
|
|
303
|
+
now,
|
|
304
|
+
owned,
|
|
305
|
+
span,
|
|
306
|
+
protocol,
|
|
307
|
+
native,
|
|
308
|
+
within,
|
|
309
|
+
activity,
|
|
310
|
+
noteInput,
|
|
311
|
+
};
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
export type PageContext = ReturnType<typeof make>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Connection identity uses the supplied Crypto service, or the host's Web Crypto on Node/Bun. */
|
|
2
|
+
import { Crypto, Effect, Option, PlatformError } from "effect";
|
|
3
|
+
|
|
4
|
+
import { Failed } from "../../BrowserError.ts";
|
|
5
|
+
import { undispatched } from "./context.ts";
|
|
6
|
+
|
|
7
|
+
// The core owns this tiny platform adapter to keep Chromium/CDP usable without another package
|
|
8
|
+
// or a new required service. Browser construction captures it once; there is no global counter.
|
|
9
|
+
const native = () =>
|
|
10
|
+
Crypto.make({
|
|
11
|
+
randomBytes: (size) => globalThis.crypto.getRandomValues(new Uint8Array(size)),
|
|
12
|
+
digest: (algorithm, data) =>
|
|
13
|
+
Effect.tryPromise({
|
|
14
|
+
try: () => globalThis.crypto.subtle.digest(algorithm, new Uint8Array(data)),
|
|
15
|
+
catch: (cause) =>
|
|
16
|
+
PlatformError.systemError({
|
|
17
|
+
module: "Crypto",
|
|
18
|
+
method: "digest",
|
|
19
|
+
_tag: "Unknown",
|
|
20
|
+
description: "Could not compute digest",
|
|
21
|
+
cause,
|
|
22
|
+
}),
|
|
23
|
+
}).pipe(Effect.map((bytes) => new Uint8Array(bytes))),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const make = Effect.gen(function* () {
|
|
27
|
+
const supplied = yield* Effect.serviceOption(Crypto.Crypto);
|
|
28
|
+
const crypto = Option.getOrElse(supplied, native);
|
|
29
|
+
|
|
30
|
+
return yield* crypto.randomUUIDv4.pipe(
|
|
31
|
+
Effect.mapError(() =>
|
|
32
|
+
undispatched("make", new Failed({ detail: "Could not name connection" })),
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
});
|
|
@@ -0,0 +1,311 @@
|
|
|
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
|
+
import type { Gate } from "../input/gate.ts";
|
|
21
|
+
|
|
22
|
+
export type Kind = "read" | "write";
|
|
23
|
+
|
|
24
|
+
export interface Ticket {
|
|
25
|
+
readonly id: number;
|
|
26
|
+
readonly kind: Kind;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Who holds the lane: no one, reads together, or one write. */
|
|
30
|
+
export type Turn =
|
|
31
|
+
| { readonly _tag: "Free" }
|
|
32
|
+
| { readonly _tag: "Reading"; readonly count: number }
|
|
33
|
+
| { readonly _tag: "Writing" };
|
|
34
|
+
|
|
35
|
+
export interface State {
|
|
36
|
+
readonly turn: Turn;
|
|
37
|
+
/** The tickets waiting, in the order they asked. */
|
|
38
|
+
readonly queue: ReadonlyArray<Ticket>;
|
|
39
|
+
/** The writes asked so far: two reads in one epoch follow the same writes. */
|
|
40
|
+
readonly epoch: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type Input =
|
|
44
|
+
| { readonly _tag: "Ask"; readonly ticket: Ticket }
|
|
45
|
+
/** The ticket gave up waiting, or ended its turn. */
|
|
46
|
+
| { readonly _tag: "Leave"; readonly ticket: Ticket };
|
|
47
|
+
|
|
48
|
+
export const initial: State = { turn: { _tag: "Free" }, queue: [], epoch: 0 };
|
|
49
|
+
|
|
50
|
+
const holders = (turn: Turn) =>
|
|
51
|
+
turn._tag === "Reading" ? turn.count : turn._tag === "Writing" ? 1 : 0;
|
|
52
|
+
|
|
53
|
+
/** Whether a ticket of `kind` asking now would hold the lane at once. */
|
|
54
|
+
export const admitsNow = ({ turn, queue }: State, kind: Kind) =>
|
|
55
|
+
queue.length === 0 && (kind === "read" ? turn._tag !== "Writing" : turn._tag === "Free");
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The operations ahead of a waiting ticket: those holding the lane and those asked before it, or
|
|
59
|
+
* all that are queued for a ticket not in the queue.
|
|
60
|
+
*/
|
|
61
|
+
export const ahead = ({ turn, queue }: State, id?: number) => {
|
|
62
|
+
const at = queue.findIndex((ticket) => ticket.id === id);
|
|
63
|
+
|
|
64
|
+
return holders(turn) + (at === -1 ? queue.length : at);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The lane after `input`, and the tickets it lets in. Tickets go in from the head of the queue
|
|
69
|
+
* only: a write once the lane is free, reads while no write holds it, so reads asked before a
|
|
70
|
+
* write go before it, and reads asked after it wait for it.
|
|
71
|
+
*/
|
|
72
|
+
export const step = (
|
|
73
|
+
state: State,
|
|
74
|
+
input: Input,
|
|
75
|
+
): { readonly state: State; readonly admitted: ReadonlyArray<Ticket> } => {
|
|
76
|
+
let { turn, queue, epoch } = state;
|
|
77
|
+
|
|
78
|
+
if (input._tag === "Ask") {
|
|
79
|
+
queue = [...queue, input.ticket];
|
|
80
|
+
if (input.ticket.kind === "write") epoch += 1;
|
|
81
|
+
} else {
|
|
82
|
+
const at = queue.findIndex((ticket) => ticket.id === input.ticket.id);
|
|
83
|
+
|
|
84
|
+
if (at !== -1) queue = queue.toSpliced(at, 1);
|
|
85
|
+
else
|
|
86
|
+
turn =
|
|
87
|
+
turn._tag === "Reading" && turn.count > 1
|
|
88
|
+
? { _tag: "Reading", count: turn.count - 1 }
|
|
89
|
+
: { _tag: "Free" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const admitted: Array<Ticket> = [];
|
|
93
|
+
|
|
94
|
+
for (const ticket of queue) {
|
|
95
|
+
if (ticket.kind === "write" ? turn._tag !== "Free" : turn._tag === "Writing") break;
|
|
96
|
+
admitted.push(ticket);
|
|
97
|
+
turn =
|
|
98
|
+
ticket.kind === "write" ? { _tag: "Writing" } : { _tag: "Reading", count: holders(turn) + 1 };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { state: { turn, queue: queue.slice(admitted.length), epoch }, admitted };
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** Whether operations on a busy page fail `Busy` at once rather than wait their turn. */
|
|
105
|
+
export const FailFast = Context.Reference<boolean>("effect-browser/internal/page/FailFast", {
|
|
106
|
+
defaultValue: () => false,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/** The id a caller gives the actions it asks for, which each records as its `correlation`. */
|
|
110
|
+
export const Correlation = Context.Reference<string | undefined>(
|
|
111
|
+
"effect-browser/internal/page/Correlation",
|
|
112
|
+
{ defaultValue: () => undefined },
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
/** A shared read in flight: its epoch, its result once it ends, and how many callers await it. */
|
|
116
|
+
interface Flight<A> {
|
|
117
|
+
readonly epoch: number;
|
|
118
|
+
readonly result: Deferred.Deferred<A, BrowserError>;
|
|
119
|
+
waiting: number;
|
|
120
|
+
fiber: Fiber.Fiber<A, BrowserError> | undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const make = (options: {
|
|
124
|
+
readonly now: () => number;
|
|
125
|
+
readonly actionTimeout: Duration.Duration;
|
|
126
|
+
/** When the page's current document began: a read is shared only within one. */
|
|
127
|
+
readonly documentAt: () => number;
|
|
128
|
+
/** The failure of a read still awaited as the page closes. */
|
|
129
|
+
readonly gone: (operation: string) => BrowserError;
|
|
130
|
+
/** The page's scope, which shared reads run in. */
|
|
131
|
+
readonly scope: Scope.Scope;
|
|
132
|
+
readonly inputGate?: Gate | undefined;
|
|
133
|
+
}) => {
|
|
134
|
+
const { now, actionTimeout } = options;
|
|
135
|
+
let state = initial;
|
|
136
|
+
let tickets = 0;
|
|
137
|
+
const admissions = new Map<number, Deferred.Deferred<void>>();
|
|
138
|
+
// Each shared read's sweep, which a write runs as it asks.
|
|
139
|
+
const sweeps: Array<() => void> = [];
|
|
140
|
+
|
|
141
|
+
const apply = (input: Input) => {
|
|
142
|
+
const next = step(state, input);
|
|
143
|
+
|
|
144
|
+
state = next.state;
|
|
145
|
+
if (input._tag === "Leave") admissions.delete(input.ticket.id);
|
|
146
|
+
for (const ticket of next.admitted) {
|
|
147
|
+
const admitted = admissions.get(ticket.id);
|
|
148
|
+
|
|
149
|
+
admissions.delete(ticket.id);
|
|
150
|
+
if (admitted !== undefined) Deferred.doneUnsafe(admitted, Exit.void);
|
|
151
|
+
}
|
|
152
|
+
if (input._tag === "Ask" && input.ticket.kind === "write") for (const sweep of sweeps) sweep();
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const busy = (operation: string, askedAt: number, id?: number) =>
|
|
156
|
+
new BrowserError({
|
|
157
|
+
operation,
|
|
158
|
+
reason: new Busy({ waitedMillis: Math.round(now() - askedAt), ahead: ahead(state, id) }),
|
|
159
|
+
dispatched: false,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Run `effect` in a turn of `kind`, waiting at most `wait` for it, or failing `Busy`. The turn
|
|
164
|
+
* ends with `effect`, however it ends.
|
|
165
|
+
*/
|
|
166
|
+
const turn =
|
|
167
|
+
(kind: Kind, operation: string, wait: Duration.Duration) =>
|
|
168
|
+
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | BrowserError, R> =>
|
|
169
|
+
Effect.uninterruptibleMask((restore) =>
|
|
170
|
+
Effect.gen(function* () {
|
|
171
|
+
const ticket: Ticket = { id: ++tickets, kind };
|
|
172
|
+
const askedAt = now();
|
|
173
|
+
|
|
174
|
+
if (kind === "write" && options.inputGate !== undefined)
|
|
175
|
+
yield* options.inputGate.check(operation);
|
|
176
|
+
|
|
177
|
+
if ((yield* FailFast) && !admitsNow(state, kind)) return yield* busy(operation, askedAt);
|
|
178
|
+
const admitted = Deferred.makeUnsafe<void>();
|
|
179
|
+
|
|
180
|
+
admissions.set(ticket.id, admitted);
|
|
181
|
+
apply({ _tag: "Ask", ticket });
|
|
182
|
+
|
|
183
|
+
const waiting = Deferred.await(admitted).pipe(
|
|
184
|
+
Effect.timeoutOrElse({
|
|
185
|
+
duration: wait,
|
|
186
|
+
orElse: () =>
|
|
187
|
+
Deferred.isDoneUnsafe(admitted)
|
|
188
|
+
? Effect.void
|
|
189
|
+
: Effect.fail(busy(operation, askedAt, ticket.id)),
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const given = yield* Effect.exit(
|
|
194
|
+
restore(
|
|
195
|
+
kind === "write" && options.inputGate !== undefined
|
|
196
|
+
? Effect.raceFirst(waiting, options.inputGate.refused(operation))
|
|
197
|
+
: waiting,
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
if (Exit.isFailure(given)) {
|
|
202
|
+
apply({ _tag: "Leave", ticket });
|
|
203
|
+
|
|
204
|
+
return yield* Effect.failCause(given.cause);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return yield* restore(effect).pipe(
|
|
208
|
+
Effect.ensuring(Effect.sync(() => apply({ _tag: "Leave", ticket }))),
|
|
209
|
+
);
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A read whose identical calls share their work: `work` runs once per epoch and document while
|
|
215
|
+
* calls await it, and with `keep` its result serves the next call after all of them gave up,
|
|
216
|
+
* within an action timeout of its end. Pictures, whose callers say how old they may be, only
|
|
217
|
+
* join.
|
|
218
|
+
*/
|
|
219
|
+
const shared = <A>(operation: string, keep: boolean) => {
|
|
220
|
+
const flights = new Map<string, Flight<A>>();
|
|
221
|
+
const kept = new Map<string, { readonly value: A; readonly until: number }>();
|
|
222
|
+
|
|
223
|
+
sweeps.push(() => {
|
|
224
|
+
kept.clear();
|
|
225
|
+
for (const flight of flights.values())
|
|
226
|
+
if (flight.waiting === 0) flight.fiber?.interruptUnsafe();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// A read in flight, its first caller waiting. However its fiber ends, even before it began,
|
|
230
|
+
// as when the page closes, its callers hear.
|
|
231
|
+
const start = (id: string, work: Effect.Effect<A, BrowserError>) =>
|
|
232
|
+
Effect.gen(function* () {
|
|
233
|
+
const flight: Flight<A> = {
|
|
234
|
+
epoch: state.epoch,
|
|
235
|
+
result: Deferred.makeUnsafe(),
|
|
236
|
+
waiting: 1,
|
|
237
|
+
fiber: undefined,
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
flights.set(id, flight);
|
|
241
|
+
flight.fiber = yield* turn(
|
|
242
|
+
"read",
|
|
243
|
+
operation,
|
|
244
|
+
actionTimeout,
|
|
245
|
+
)(work).pipe(
|
|
246
|
+
// The caller chose whether to wait; the work it started serves others too.
|
|
247
|
+
Effect.provideService(FailFast, false),
|
|
248
|
+
Effect.forkIn(options.scope),
|
|
249
|
+
);
|
|
250
|
+
flight.fiber.addObserver((exit) => {
|
|
251
|
+
flights.delete(id);
|
|
252
|
+
if (keep && Exit.isSuccess(exit) && flight.waiting === 0 && flight.epoch === state.epoch)
|
|
253
|
+
kept.set(id, { value: exit.value, until: now() + Duration.toMillis(actionTimeout) });
|
|
254
|
+
Deferred.doneUnsafe(
|
|
255
|
+
flight.result,
|
|
256
|
+
Exit.hasInterrupts(exit) ? Effect.fail(options.gone(operation)) : exit,
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
return flight;
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const join = (id: string, work: Effect.Effect<A, BrowserError>) =>
|
|
264
|
+
Effect.acquireUseRelease(
|
|
265
|
+
Effect.suspend(() => {
|
|
266
|
+
const flying = flights.get(id);
|
|
267
|
+
|
|
268
|
+
if (flying === undefined) return start(id, work);
|
|
269
|
+
flying.waiting += 1;
|
|
270
|
+
|
|
271
|
+
return Effect.as(Effect.annotateCurrentSpan("shared", "joined"), flying);
|
|
272
|
+
}),
|
|
273
|
+
(flight) => Deferred.await(flight.result),
|
|
274
|
+
(flight) =>
|
|
275
|
+
Effect.sync(() => {
|
|
276
|
+
flight.waiting -= 1;
|
|
277
|
+
if (flight.waiting === 0 && flight.epoch < state.epoch) flight.fiber?.interruptUnsafe();
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
return (key: string, work: Effect.Effect<A, BrowserError>): Effect.Effect<A, BrowserError> =>
|
|
282
|
+
Effect.gen(function* () {
|
|
283
|
+
const id = `${state.epoch} ${options.documentAt()} ${key}`;
|
|
284
|
+
const at = now();
|
|
285
|
+
|
|
286
|
+
for (const [old, { until }] of kept) if (until < at) kept.delete(old);
|
|
287
|
+
const hit = kept.get(id);
|
|
288
|
+
|
|
289
|
+
if (hit !== undefined) {
|
|
290
|
+
kept.delete(id);
|
|
291
|
+
yield* Effect.annotateCurrentSpan("shared", "kept");
|
|
292
|
+
|
|
293
|
+
return hit.value;
|
|
294
|
+
}
|
|
295
|
+
if ((yield* FailFast) && !admitsNow(state, "read")) return yield* busy(operation, at);
|
|
296
|
+
|
|
297
|
+
return yield* join(id, work);
|
|
298
|
+
});
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
/** A turn alone, for an action: `wait` bounds the wait for it. */
|
|
303
|
+
write: (operation: string, wait: Duration.Duration) => turn("write", operation, wait),
|
|
304
|
+
/** A turn beside other reads, waited for within the action timeout. */
|
|
305
|
+
read: (operation: string, wait: Duration.Duration = actionTimeout) =>
|
|
306
|
+
turn("read", operation, wait),
|
|
307
|
+
shared,
|
|
308
|
+
};
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
export type Lane = ReturnType<typeof make>;
|