@effect-agent/platform-cloudflare 0.1.0-beta.9 → 0.1.0-beta.91
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/dist/Alarm.d.mts +188 -0
- package/dist/Alarm.mjs +451 -0
- package/dist/Alarm.mjs.map +1 -0
- package/dist/BrowserRestCapture.d.mts +35 -0
- package/dist/BrowserRestCapture.mjs +238 -0
- package/dist/BrowserRestCapture.mjs.map +1 -0
- package/dist/BrowserRestCrawl.d.mts +17 -0
- package/dist/BrowserRestCrawl.mjs +244 -0
- package/dist/BrowserRestCrawl.mjs.map +1 -0
- package/dist/CloudflareAiGateway.d.mts +64 -0
- package/dist/CloudflareAiGateway.mjs +70 -0
- package/dist/CloudflareAiGateway.mjs.map +1 -0
- package/dist/CloudflareBindings.d.mts +115 -0
- package/dist/CloudflareBindings.mjs +113 -0
- package/dist/CloudflareBindings.mjs.map +1 -0
- package/dist/CloudflareBrowser-DV6kya1H.mjs +495 -0
- package/dist/CloudflareBrowser-DV6kya1H.mjs.map +1 -0
- package/dist/CloudflareBrowser-Doe1M7R5.d.mts +80 -0
- package/dist/CloudflareBrowser.d.mts +2 -0
- package/dist/CloudflareBrowser.mjs +2 -0
- package/dist/CloudflareCodeMode.d.mts +44 -0
- package/dist/CloudflareCodeMode.mjs +616 -0
- package/dist/CloudflareCodeMode.mjs.map +1 -0
- package/dist/CloudflareConfig-f3CqTel1.d.mts +101 -0
- package/dist/CloudflareConfig.d.mts +2 -0
- package/dist/CloudflareConfig.mjs +122 -0
- package/dist/CloudflareConfig.mjs.map +1 -0
- package/dist/CloudflareMemory.d.mts +142 -0
- package/dist/CloudflareMemory.mjs +202 -0
- package/dist/CloudflareMemory.mjs.map +1 -0
- package/dist/CloudflareScheduling.d.mts +52 -0
- package/dist/CloudflareScheduling.mjs +396 -0
- package/dist/CloudflareScheduling.mjs.map +1 -0
- package/dist/CloudflareSubscriptions.d.mts +87 -0
- package/dist/CloudflareSubscriptions.mjs +530 -0
- package/dist/CloudflareSubscriptions.mjs.map +1 -0
- package/dist/CloudflareThreadClient.d.mts +1496 -0
- package/dist/CloudflareThreadClient.mjs +382 -0
- package/dist/CloudflareThreadClient.mjs.map +1 -0
- package/dist/InteractiveBrowser-DUE_m12-.d.mts +161 -0
- package/dist/InteractiveBrowser-DupDtfw2.mjs +1169 -0
- package/dist/InteractiveBrowser-DupDtfw2.mjs.map +1 -0
- package/dist/InteractiveBrowser.d.mts +2 -0
- package/dist/InteractiveBrowser.mjs +2 -0
- package/dist/ProtectedBrowser.d.mts +123 -0
- package/dist/ProtectedBrowser.mjs +1135 -0
- package/dist/ProtectedBrowser.mjs.map +1 -0
- package/dist/ThreadObject-CIbmXZCY.d.mts +812 -0
- package/dist/ThreadObject-DvRG0dDz.mjs +835 -0
- package/dist/ThreadObject-DvRG0dDz.mjs.map +1 -0
- package/dist/ThreadObject.d.mts +2 -0
- package/dist/ThreadObject.mjs +3 -0
- package/dist/WakeScheduler.d.mts +23 -0
- package/dist/WakeScheduler.mjs +57 -0
- package/dist/WakeScheduler.mjs.map +1 -0
- package/dist/boundary-BguazVkh.mjs +42 -0
- package/dist/boundary-BguazVkh.mjs.map +1 -0
- package/dist/index.d.mts +13 -1548
- package/dist/index.mjs +13 -1636
- package/dist/prepared-admission-DU0_T-ev.mjs +73 -0
- package/dist/prepared-admission-DU0_T-ev.mjs.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/package.json +1 -53
- package/src/Alarm.ts +973 -0
- package/src/BrowserRestCapture.ts +477 -0
- package/src/BrowserRestCrawl.ts +540 -0
- package/src/CloudflareAiGateway.ts +170 -0
- package/src/CloudflareBindings.ts +199 -0
- package/src/CloudflareBrowser.ts +15 -0
- package/src/{code-mode-executor.ts → CloudflareCodeMode.ts} +372 -204
- package/src/{config.ts → CloudflareConfig.ts} +9 -8
- package/src/CloudflareMemory.ts +420 -0
- package/src/CloudflareScheduling.ts +747 -0
- package/src/CloudflareSubscriptions.ts +1041 -0
- package/src/CloudflareThreadClient.ts +809 -0
- package/src/InteractiveBrowser.ts +2354 -0
- package/src/ProtectedBrowser.ts +9 -0
- package/src/ThreadObject.ts +1179 -0
- package/src/{wake-scheduler.ts → WakeScheduler.ts} +32 -26
- package/src/index.ts +12 -23
- package/src/internal/boundary.ts +55 -0
- package/src/internal/browser-quick-action.ts +857 -0
- package/src/internal/browser-session-lifecycle.ts +160 -0
- package/src/internal/layers.ts +751 -0
- package/src/internal/message-delivery.ts +152 -0
- package/src/internal/prepared-admission.ts +116 -0
- package/src/internal/progress-wait.ts +121 -0
- package/src/internal/transport.ts +44 -0
- package/src/protected-browser/binding.ts +313 -0
- package/src/protected-browser/host.ts +344 -0
- package/src/protected-browser/inspect-frame.ts +125 -0
- package/src/protected-browser/native.ts +445 -0
- package/src/protected-browser/policy.ts +821 -0
- package/dist/index.mjs.map +0 -1
- package/src/alarm.ts +0 -334
- package/src/bindings.ts +0 -145
- package/src/client.ts +0 -646
- package/src/conversation-object.ts +0 -768
- package/src/layers.ts +0 -376
- package/src/transport.ts +0 -38
|
@@ -0,0 +1,2354 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
|
|
3
|
+
import puppeteer, {
|
|
4
|
+
type Browser,
|
|
5
|
+
type BrowserContext,
|
|
6
|
+
type CDPSession,
|
|
7
|
+
type HTTPRequest,
|
|
8
|
+
type Page,
|
|
9
|
+
} from "@cloudflare/puppeteer";
|
|
10
|
+
import {
|
|
11
|
+
Context,
|
|
12
|
+
Clock,
|
|
13
|
+
Duration,
|
|
14
|
+
Effect,
|
|
15
|
+
Layer,
|
|
16
|
+
Redacted,
|
|
17
|
+
Ref,
|
|
18
|
+
Schema,
|
|
19
|
+
Semaphore,
|
|
20
|
+
type Scope,
|
|
21
|
+
} from "effect";
|
|
22
|
+
import {
|
|
23
|
+
BrowserActionResult,
|
|
24
|
+
BrowserScreenshotRequest,
|
|
25
|
+
BrowserScrollRequest,
|
|
26
|
+
BrowserNavigationResult,
|
|
27
|
+
BrowserTextResult,
|
|
28
|
+
InteractiveBrowser,
|
|
29
|
+
InteractiveBrowserActionError,
|
|
30
|
+
InteractiveBrowserBusyError,
|
|
31
|
+
InteractiveBrowserCapacityError,
|
|
32
|
+
InteractiveBrowserExpiredError,
|
|
33
|
+
InteractiveBrowserLimitError,
|
|
34
|
+
InteractiveBrowserPolicy,
|
|
35
|
+
InteractiveBrowserPolicyDeniedError,
|
|
36
|
+
InteractiveBrowserProtocolError,
|
|
37
|
+
InteractiveBrowserTargetUrl,
|
|
38
|
+
InteractiveBrowserUnsupportedError,
|
|
39
|
+
type BrowserHandle,
|
|
40
|
+
type InteractiveBrowserError,
|
|
41
|
+
type InteractiveBrowserNetworkPolicy,
|
|
42
|
+
} from "effect-agent/interactive-browser";
|
|
43
|
+
import { PageScreenshotResult } from "effect-agent/page-screenshot";
|
|
44
|
+
import { SandboxImplementation } from "effect-agent/sandbox";
|
|
45
|
+
|
|
46
|
+
import {
|
|
47
|
+
BrowserRunSessionLifecycle,
|
|
48
|
+
type BrowserRunLifecycleOptions,
|
|
49
|
+
} from "./internal/browser-session-lifecycle.ts";
|
|
50
|
+
|
|
51
|
+
export {
|
|
52
|
+
BrowserRunCleanupError,
|
|
53
|
+
BrowserRunSessionLifecycle,
|
|
54
|
+
} from "./internal/browser-session-lifecycle.ts";
|
|
55
|
+
|
|
56
|
+
export const browserRunInteractiveImplementation = SandboxImplementation.make({
|
|
57
|
+
isolation: "isolated",
|
|
58
|
+
identity: "cloudflare-browser-run-interactive",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const MIN_KEEP_ALIVE_MILLIS = 10_000;
|
|
62
|
+
const MAX_KEEP_ALIVE_MILLIS = 600_000;
|
|
63
|
+
const MAX_TEXT_LENGTH = 8 * 1024 * 1024;
|
|
64
|
+
const MAX_SCREENSHOT_BYTES = 8 * 1024 * 1024;
|
|
65
|
+
const MIN_LIVE_VIEW_EXPIRY_MILLIS = 60_000;
|
|
66
|
+
const MAX_LIVE_VIEW_EXPIRY_MILLIS = 60 * 60_000;
|
|
67
|
+
const MAX_HANDOFF_TIMEOUT_MILLIS = 30 * 60_000;
|
|
68
|
+
const MAX_HOST_TEXT_LENGTH = 8 * 1024;
|
|
69
|
+
const CLEANUP_STEP_TIMEOUT_MILLIS = 10_000;
|
|
70
|
+
const ACTION_NETWORK_QUIET_MILLIS = 200;
|
|
71
|
+
const ACTION_NETWORK_SETTLE_MILLIS = 2_000;
|
|
72
|
+
const ACTION_POST_STATE_MILLIS = 250;
|
|
73
|
+
const MAX_OBSERVED_CONTROLS = 64;
|
|
74
|
+
const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0));
|
|
75
|
+
|
|
76
|
+
const BoundedHostText = Schema.String.check(
|
|
77
|
+
Schema.isMinLength(1),
|
|
78
|
+
Schema.isMaxLength(MAX_HOST_TEXT_LENGTH),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const BoundedRemoteText = Schema.String.check(Schema.isMaxLength(MAX_TEXT_LENGTH));
|
|
82
|
+
|
|
83
|
+
const TextObservation = Schema.Union([
|
|
84
|
+
Schema.Struct({ _tag: Schema.Literal("Text"), text: BoundedRemoteText }),
|
|
85
|
+
Schema.Struct({ _tag: Schema.Literal("MissingElement") }),
|
|
86
|
+
Schema.Struct({ _tag: Schema.Literal("OverLimit"), observed: Schema.Natural }),
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
const ActionTargetState = Schema.Struct({
|
|
90
|
+
matchCount: Schema.Natural,
|
|
91
|
+
invalidSelector: Schema.optionalKey(Schema.Boolean),
|
|
92
|
+
kind: Schema.optionalKey(
|
|
93
|
+
Schema.Literals(["button", "checkbox", "radio", "select", "text", "link", "other"]),
|
|
94
|
+
),
|
|
95
|
+
checked: Schema.optionalKey(Schema.Boolean),
|
|
96
|
+
selected: Schema.optionalKey(Schema.Boolean),
|
|
97
|
+
disabled: Schema.optionalKey(Schema.Boolean),
|
|
98
|
+
required: Schema.optionalKey(Schema.Boolean),
|
|
99
|
+
valid: Schema.optionalKey(Schema.Boolean),
|
|
100
|
+
formValid: Schema.optionalKey(Schema.Boolean),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const ActionNetworkState = Schema.Struct({
|
|
104
|
+
total: Schema.Natural,
|
|
105
|
+
status2xx: Schema.Natural,
|
|
106
|
+
status3xx: Schema.Natural,
|
|
107
|
+
status4xx: Schema.Natural,
|
|
108
|
+
status5xx: Schema.Natural,
|
|
109
|
+
failed: Schema.Natural,
|
|
110
|
+
pending: Schema.Natural,
|
|
111
|
+
settleTimedOut: Schema.Boolean,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const ActionObservation = Schema.Struct({
|
|
115
|
+
before: ActionTargetState,
|
|
116
|
+
after: Schema.optionalKey(ActionTargetState),
|
|
117
|
+
afterUnavailable: Schema.Boolean,
|
|
118
|
+
network: ActionNetworkState,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const PageObservation = Schema.fromJsonString(
|
|
122
|
+
Schema.Struct({
|
|
123
|
+
pageText: BoundedRemoteText,
|
|
124
|
+
selectorMatchCount: Schema.Natural,
|
|
125
|
+
controlsTruncated: Schema.Boolean,
|
|
126
|
+
controls: Schema.Array(
|
|
127
|
+
Schema.Struct({
|
|
128
|
+
selector: BoundedRemoteText,
|
|
129
|
+
kind: BoundedRemoteText,
|
|
130
|
+
label: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(200))),
|
|
131
|
+
checked: Schema.optionalKey(Schema.Boolean),
|
|
132
|
+
selected: Schema.optionalKey(Schema.Boolean),
|
|
133
|
+
disabled: Schema.optionalKey(Schema.Boolean),
|
|
134
|
+
required: Schema.optionalKey(Schema.Boolean),
|
|
135
|
+
valid: Schema.optionalKey(Schema.Boolean),
|
|
136
|
+
formValid: Schema.optionalKey(Schema.Boolean),
|
|
137
|
+
}),
|
|
138
|
+
).check(Schema.isMaxLength(MAX_OBSERVED_CONTROLS)),
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const PngBytes = Schema.Uint8Array.check(
|
|
143
|
+
Schema.isMaxLength(MAX_SCREENSHOT_BYTES),
|
|
144
|
+
Schema.makeFilter(
|
|
145
|
+
(bytes) =>
|
|
146
|
+
bytes.length >= 8 &&
|
|
147
|
+
bytes[0] === 0x89 &&
|
|
148
|
+
bytes[1] === 0x50 &&
|
|
149
|
+
bytes[2] === 0x4e &&
|
|
150
|
+
bytes[3] === 0x47 &&
|
|
151
|
+
bytes[4] === 0x0d &&
|
|
152
|
+
bytes[5] === 0x0a &&
|
|
153
|
+
bytes[6] === 0x1a &&
|
|
154
|
+
bytes[7] === 0x0a,
|
|
155
|
+
{ title: "PNG bytes" },
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const BrowserRunSessionId = Schema.String.check(
|
|
160
|
+
Schema.isMinLength(1),
|
|
161
|
+
Schema.isMaxLength(256),
|
|
162
|
+
Schema.makeFilter((value) => /^[A-Za-z0-9_-]+$/.test(value), {
|
|
163
|
+
title: "a Browser Run session identifier",
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const LiveViewUrl = Schema.String.check(
|
|
168
|
+
Schema.isMaxLength(MAX_HOST_TEXT_LENGTH),
|
|
169
|
+
Schema.makeFilter(
|
|
170
|
+
(value) => {
|
|
171
|
+
try {
|
|
172
|
+
const url = new URL(value);
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
url.protocol === "https:" &&
|
|
176
|
+
url.host === "live.browser.run" &&
|
|
177
|
+
url.username === "" &&
|
|
178
|
+
url.password === "" &&
|
|
179
|
+
url.pathname === "/ui/view" &&
|
|
180
|
+
url.searchParams.get("mode") === "tab" &&
|
|
181
|
+
(url.searchParams.get("wss") ?? "").startsWith("live.browser.run/api/devtools/browser/")
|
|
182
|
+
);
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
{ title: "a Cloudflare Live View HTTPS URL" },
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const LiveViewObservation = Schema.Struct({ devtoolsFrontendUrl: LiveViewUrl });
|
|
192
|
+
const HandoffObservation = Schema.Struct({ handoffId: BoundedHostText });
|
|
193
|
+
|
|
194
|
+
const HandoffDuration = Schema.Natural.check(
|
|
195
|
+
Schema.isLessThanOrEqualTo(MAX_HANDOFF_TIMEOUT_MILLIS),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const HandoffStateObservation = Schema.Union([
|
|
199
|
+
Schema.Struct({
|
|
200
|
+
active: Schema.Literal(true),
|
|
201
|
+
handoffId: BoundedHostText,
|
|
202
|
+
durationMs: HandoffDuration,
|
|
203
|
+
}),
|
|
204
|
+
Schema.Struct({
|
|
205
|
+
active: Schema.Literal(false),
|
|
206
|
+
handoffId: Schema.optionalKey(BoundedHostText),
|
|
207
|
+
durationMs: Schema.optionalKey(HandoffDuration),
|
|
208
|
+
}),
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Host presentation state in CSS pixels. Dimensions are integers in 1..2048,
|
|
213
|
+
* density is finite in 1..2 (default 1), and neither scaled dimension may exceed
|
|
214
|
+
* 2048 pixels. Mobile, touch, and orientation emulation are not supported.
|
|
215
|
+
*/
|
|
216
|
+
export class BrowserRunViewport extends Schema.Class<BrowserRunViewport>("BrowserRunViewport")(
|
|
217
|
+
Schema.Struct({
|
|
218
|
+
width: PositiveInt.check(Schema.isLessThanOrEqualTo(2_048)),
|
|
219
|
+
height: PositiveInt.check(Schema.isLessThanOrEqualTo(2_048)),
|
|
220
|
+
deviceScaleFactor: Schema.optionalKey(
|
|
221
|
+
Schema.Finite.check(Schema.isBetween({ minimum: 1, maximum: 2 })),
|
|
222
|
+
),
|
|
223
|
+
}).check(
|
|
224
|
+
Schema.makeFilter(
|
|
225
|
+
(viewport) =>
|
|
226
|
+
Math.max(viewport.width, viewport.height) * (viewport.deviceScaleFactor ?? 1) <= 2_048,
|
|
227
|
+
{ title: "a viewport with scaled dimensions no larger than 2048 pixels" },
|
|
228
|
+
),
|
|
229
|
+
),
|
|
230
|
+
) {}
|
|
231
|
+
|
|
232
|
+
const decodeViewport = (input: BrowserRunViewport) =>
|
|
233
|
+
Schema.decodeEffect(BrowserRunViewport)(input, { onExcessProperty: "error" }).pipe(
|
|
234
|
+
Effect.mapError(() => policyError("The browser viewport is malformed")),
|
|
235
|
+
// Copy only presentation fields, including for Schema class instances.
|
|
236
|
+
Effect.map((viewport) => ({
|
|
237
|
+
width: viewport.width,
|
|
238
|
+
height: viewport.height,
|
|
239
|
+
deviceScaleFactor: viewport.deviceScaleFactor ?? 1,
|
|
240
|
+
})),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
/** Host-only request for a redacted Cloudflare Live View URL. */
|
|
244
|
+
export class BrowserRunLiveViewRequest extends Schema.Class<BrowserRunLiveViewRequest>(
|
|
245
|
+
"BrowserRunLiveViewRequest",
|
|
246
|
+
)({
|
|
247
|
+
mode: Schema.Literal("tab"),
|
|
248
|
+
expiresInMs: PositiveInt.check(
|
|
249
|
+
Schema.isBetween({
|
|
250
|
+
minimum: MIN_LIVE_VIEW_EXPIRY_MILLIS,
|
|
251
|
+
maximum: MAX_LIVE_VIEW_EXPIRY_MILLIS,
|
|
252
|
+
}),
|
|
253
|
+
),
|
|
254
|
+
}) {}
|
|
255
|
+
|
|
256
|
+
export class BrowserRunLiveViewResult extends Schema.Class<BrowserRunLiveViewResult>(
|
|
257
|
+
"BrowserRunLiveViewResult",
|
|
258
|
+
)({ devtoolsFrontendUrl: Schema.Redacted(LiveViewUrl) }) {}
|
|
259
|
+
|
|
260
|
+
/** Start one bounded handoff; controller ownership remains a consumer concern. */
|
|
261
|
+
export class BrowserRunHandoffRequest extends Schema.Class<BrowserRunHandoffRequest>(
|
|
262
|
+
"BrowserRunHandoffRequest",
|
|
263
|
+
)({
|
|
264
|
+
instructions: BoundedHostText.check(Schema.isMaxLength(1_024)),
|
|
265
|
+
timeout: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_HANDOFF_TIMEOUT_MILLIS)),
|
|
266
|
+
}) {}
|
|
267
|
+
|
|
268
|
+
export class BrowserRunHandoffResult extends Schema.Class<BrowserRunHandoffResult>(
|
|
269
|
+
"BrowserRunHandoffResult",
|
|
270
|
+
)({ handoffId: Schema.Redacted(BoundedHostText) }) {}
|
|
271
|
+
|
|
272
|
+
export class BrowserRunHandoffState extends Schema.Class<BrowserRunHandoffState>(
|
|
273
|
+
"BrowserRunHandoffState",
|
|
274
|
+
)({
|
|
275
|
+
active: Schema.Boolean,
|
|
276
|
+
handoffId: Schema.optionalKey(Schema.Redacted(BoundedHostText)),
|
|
277
|
+
durationMs: Schema.optionalKey(HandoffDuration),
|
|
278
|
+
}) {}
|
|
279
|
+
|
|
280
|
+
type BrowserFailure = typeof InteractiveBrowserError.Type;
|
|
281
|
+
type BrowserOperation = InteractiveBrowserActionError["operation"];
|
|
282
|
+
|
|
283
|
+
interface InteractiveBrowserPolicySnapshot {
|
|
284
|
+
readonly network: Exclude<InteractiveBrowserNetworkPolicy, { readonly _tag: "PublicWeb" }>;
|
|
285
|
+
readonly maxActions: number;
|
|
286
|
+
readonly maxElapsedMillis: number;
|
|
287
|
+
readonly maxReturnedBytes: number;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** One intercepted Puppeteer request, narrowed to the policy-relevant surface. */
|
|
291
|
+
export interface BrowserRunInteractiveRequest {
|
|
292
|
+
readonly url: () => string;
|
|
293
|
+
readonly abort: () => Promise<void>;
|
|
294
|
+
readonly continue: () => Promise<void>;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export type BrowserRunInteractiveRequestListener = (request: BrowserRunInteractiveRequest) => void;
|
|
298
|
+
|
|
299
|
+
export type BrowserRunCloudflareCommand =
|
|
300
|
+
| "Cloudflare.getLiveView"
|
|
301
|
+
| "Cloudflare.handoff"
|
|
302
|
+
| "Cloudflare.getHandoffState";
|
|
303
|
+
|
|
304
|
+
/** Narrow CDP boundary for Cloudflare commands absent from the pinned protocol types. */
|
|
305
|
+
export interface BrowserRunInteractiveCdpSession {
|
|
306
|
+
readonly send: (command: BrowserRunCloudflareCommand, parameters: unknown) => Promise<unknown>;
|
|
307
|
+
readonly detach: () => Promise<void>;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Narrow page boundary used by deterministic tests; SDK values remain in this package. */
|
|
311
|
+
export interface BrowserRunInteractivePage {
|
|
312
|
+
readonly close: () => Promise<void>;
|
|
313
|
+
readonly setBypassServiceWorker: (enabled: boolean) => Promise<void>;
|
|
314
|
+
readonly setRequestInterception: (enabled: boolean) => Promise<void>;
|
|
315
|
+
readonly onRequest: (listener: BrowserRunInteractiveRequestListener) => void;
|
|
316
|
+
readonly offRequest: (listener: BrowserRunInteractiveRequestListener) => void;
|
|
317
|
+
readonly goto: (url: string) => Promise<void>;
|
|
318
|
+
readonly url: () => unknown;
|
|
319
|
+
readonly readText: (selector: string | undefined, maximumBytes: number) => Promise<unknown>;
|
|
320
|
+
readonly fill: (
|
|
321
|
+
selector: string,
|
|
322
|
+
value: string,
|
|
323
|
+
signal: AbortSignal,
|
|
324
|
+
onDispatch: () => void,
|
|
325
|
+
) => Promise<unknown>;
|
|
326
|
+
readonly click: (
|
|
327
|
+
selector: string,
|
|
328
|
+
signal: AbortSignal,
|
|
329
|
+
onDispatch: () => void,
|
|
330
|
+
) => Promise<unknown>;
|
|
331
|
+
readonly screenshot: (fullPage: boolean) => Promise<unknown>;
|
|
332
|
+
readonly scroll: (deltaX: number, deltaY: number) => Promise<void>;
|
|
333
|
+
readonly setViewport: (viewport: BrowserRunViewport) => Promise<void>;
|
|
334
|
+
readonly createCdpSession: () => Promise<BrowserRunInteractiveCdpSession>;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export interface BrowserRunInteractiveContext {
|
|
338
|
+
readonly newPage: () => Promise<BrowserRunInteractivePage>;
|
|
339
|
+
readonly close: () => Promise<void>;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export interface BrowserRunInteractiveBrowser {
|
|
343
|
+
readonly createContext: () => Promise<BrowserRunInteractiveContext>;
|
|
344
|
+
readonly close: () => Promise<void>;
|
|
345
|
+
readonly sessionId: () => unknown;
|
|
346
|
+
readonly isConnected: () => boolean;
|
|
347
|
+
readonly onDisconnected: (listener: () => void) => void;
|
|
348
|
+
readonly offDisconnected: (listener: () => void) => void;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Host-supplied Browser Run binding projected into one fakeable launch operation. */
|
|
352
|
+
export class BrowserRunInteractiveBinding extends Context.Service<
|
|
353
|
+
BrowserRunInteractiveBinding,
|
|
354
|
+
{
|
|
355
|
+
readonly launch: (keepAliveMillis: number) => Promise<BrowserRunInteractiveBrowser>;
|
|
356
|
+
/** Success proves whole-browser termination or exact-session absence. */
|
|
357
|
+
readonly closeSession: (
|
|
358
|
+
sessionId: Redacted.Redacted<string>,
|
|
359
|
+
) => Effect.Effect<void, InteractiveBrowserError>;
|
|
360
|
+
}
|
|
361
|
+
>()("@effect-agent/platform-cloudflare/BrowserRunInteractiveBinding") {
|
|
362
|
+
static layer(options: {
|
|
363
|
+
readonly browser: BrowserRun;
|
|
364
|
+
readonly viewport?: BrowserRunViewport;
|
|
365
|
+
}): Layer.Layer<
|
|
366
|
+
BrowserRunInteractiveBinding,
|
|
367
|
+
InteractiveBrowserPolicyDeniedError,
|
|
368
|
+
BrowserRunSessionLifecycle
|
|
369
|
+
> {
|
|
370
|
+
return Layer.effect(BrowserRunInteractiveBinding)(
|
|
371
|
+
Effect.gen(function* () {
|
|
372
|
+
const lifecycle = yield* BrowserRunSessionLifecycle;
|
|
373
|
+
|
|
374
|
+
const viewport =
|
|
375
|
+
options.viewport === undefined ? undefined : yield* decodeViewport(options.viewport);
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
launch: async (keepAliveMillis: number) =>
|
|
379
|
+
makeProductionBrowser(
|
|
380
|
+
await puppeteer.launch(options.browser, {
|
|
381
|
+
keep_alive: keepAliveMillis,
|
|
382
|
+
...(viewport === undefined ? {} : { defaultViewport: { ...viewport } }),
|
|
383
|
+
}),
|
|
384
|
+
),
|
|
385
|
+
closeSession: (sessionId: Redacted.Redacted<string>) =>
|
|
386
|
+
lifecycle
|
|
387
|
+
.close(sessionId)
|
|
388
|
+
.pipe(Effect.mapError((cause) => actionError("close", cause))),
|
|
389
|
+
};
|
|
390
|
+
}),
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export interface BrowserRunInteractiveSession {
|
|
396
|
+
readonly handle: BrowserHandle;
|
|
397
|
+
readonly sessionId: Redacted.Redacted<string>;
|
|
398
|
+
/**
|
|
399
|
+
* Resize without charging an agent action or changing emulation modes. Shares
|
|
400
|
+
* the handle's fail-fast lock, page-policy preflight, and elapsed deadline.
|
|
401
|
+
* A timeout or interruption leaves the session unusable; no resize is retried.
|
|
402
|
+
* The host must authorize callers. No viewer ownership or durable state is added.
|
|
403
|
+
*/
|
|
404
|
+
readonly resizeViewport: (
|
|
405
|
+
viewport: BrowserRunViewport,
|
|
406
|
+
) => Effect.Effect<void, InteractiveBrowserError>;
|
|
407
|
+
readonly getLiveView: (
|
|
408
|
+
request: BrowserRunLiveViewRequest,
|
|
409
|
+
) => Effect.Effect<BrowserRunLiveViewResult, InteractiveBrowserError>;
|
|
410
|
+
readonly handoff: (
|
|
411
|
+
request: BrowserRunHandoffRequest,
|
|
412
|
+
) => Effect.Effect<BrowserRunHandoffResult, InteractiveBrowserError>;
|
|
413
|
+
readonly getHandoffState: Effect.Effect<BrowserRunHandoffState, InteractiveBrowserError>;
|
|
414
|
+
readonly close: Effect.Effect<void, InteractiveBrowserError>;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Cloudflare host authority kept separate from the provider-neutral browser handle. */
|
|
418
|
+
export class BrowserRunInteractiveHost extends Context.Service<
|
|
419
|
+
BrowserRunInteractiveHost,
|
|
420
|
+
{
|
|
421
|
+
readonly cleanupSemantics?: "confirmed-terminal";
|
|
422
|
+
readonly open: (
|
|
423
|
+
policy: InteractiveBrowserPolicy,
|
|
424
|
+
) => Effect.Effect<BrowserRunInteractiveSession, InteractiveBrowserError, Scope.Scope>;
|
|
425
|
+
readonly closeSession: (
|
|
426
|
+
sessionId: Redacted.Redacted<string>,
|
|
427
|
+
) => Effect.Effect<void, InteractiveBrowserError>;
|
|
428
|
+
}
|
|
429
|
+
>()("@effect-agent/platform-cloudflare/BrowserRunInteractiveHost") {}
|
|
430
|
+
|
|
431
|
+
const makeProductionRequest = (request: HTTPRequest): BrowserRunInteractiveRequest => ({
|
|
432
|
+
url: () => request.url(),
|
|
433
|
+
abort: () => request.abort("blockedbyclient"),
|
|
434
|
+
continue: () => request.continue(),
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const makeProductionCdpSession = (session: CDPSession): BrowserRunInteractiveCdpSession => ({
|
|
438
|
+
send: async (command, parameters) => {
|
|
439
|
+
const send = Reflect.get(session, "send");
|
|
440
|
+
|
|
441
|
+
return await Reflect.apply(send, session, [command, parameters]);
|
|
442
|
+
},
|
|
443
|
+
detach: () => session.detach(),
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
class BrowserRunActionUndispatched extends Error {
|
|
447
|
+
readonly matchCount: number;
|
|
448
|
+
|
|
449
|
+
constructor(matchCount: number) {
|
|
450
|
+
super("The browser action selector did not resolve to exactly one element");
|
|
451
|
+
this.matchCount = matchCount;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const readActionTarget = (page: Page, selector: string): Promise<unknown> =>
|
|
456
|
+
page.evaluate((requestedSelector) => {
|
|
457
|
+
const pageDocument = Reflect.get(globalThis, "document");
|
|
458
|
+
let matches;
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
matches = Reflect.apply(Reflect.get(pageDocument, "querySelectorAll"), pageDocument, [
|
|
462
|
+
requestedSelector,
|
|
463
|
+
]);
|
|
464
|
+
} catch (cause) {
|
|
465
|
+
if (cause instanceof Error && cause.name === "SyntaxError") {
|
|
466
|
+
return { matchCount: 0, invalidSelector: true };
|
|
467
|
+
}
|
|
468
|
+
throw cause;
|
|
469
|
+
}
|
|
470
|
+
if (typeof matches !== "object" || matches === null) throw new Error("Invalid query result");
|
|
471
|
+
const matchCount = Math.min(10_000, Reflect.get(matches, "length"));
|
|
472
|
+
const element = Reflect.get(matches, 0);
|
|
473
|
+
|
|
474
|
+
if (element === undefined) return { matchCount };
|
|
475
|
+
|
|
476
|
+
const associated = Reflect.get(element, "control") ?? element;
|
|
477
|
+
const tagName = String(Reflect.get(associated, "tagName") ?? "").toLowerCase();
|
|
478
|
+
const inputType = String(Reflect.get(associated, "type") ?? "").toLowerCase();
|
|
479
|
+
|
|
480
|
+
const role = String(
|
|
481
|
+
Reflect.apply(Reflect.get(element, "getAttribute"), element, ["role"]) ?? "",
|
|
482
|
+
).toLowerCase();
|
|
483
|
+
|
|
484
|
+
const kind =
|
|
485
|
+
tagName === "button" || role === "button"
|
|
486
|
+
? "button"
|
|
487
|
+
: inputType === "checkbox" || role === "checkbox" || role === "switch"
|
|
488
|
+
? "checkbox"
|
|
489
|
+
: inputType === "radio" || role === "radio"
|
|
490
|
+
? "radio"
|
|
491
|
+
: tagName === "select"
|
|
492
|
+
? "select"
|
|
493
|
+
: tagName === "input" || tagName === "textarea"
|
|
494
|
+
? "text"
|
|
495
|
+
: tagName === "a"
|
|
496
|
+
? "link"
|
|
497
|
+
: "other";
|
|
498
|
+
|
|
499
|
+
const checked = Reflect.get(associated, "checked");
|
|
500
|
+
|
|
501
|
+
const selected =
|
|
502
|
+
tagName === "select"
|
|
503
|
+
? Reflect.get(associated, "selectedIndex") >= 0
|
|
504
|
+
: Reflect.get(associated, "selected");
|
|
505
|
+
|
|
506
|
+
const disabled = Reflect.get(associated, "disabled");
|
|
507
|
+
const required = Reflect.get(associated, "required");
|
|
508
|
+
const validity = Reflect.get(associated, "validity");
|
|
509
|
+
const form = Reflect.get(associated, "form");
|
|
510
|
+
|
|
511
|
+
const formMatches =
|
|
512
|
+
form === null || form === undefined ? undefined : Reflect.get(form, "matches");
|
|
513
|
+
|
|
514
|
+
const ariaChecked = Reflect.apply(Reflect.get(element, "getAttribute"), element, [
|
|
515
|
+
"aria-checked",
|
|
516
|
+
]);
|
|
517
|
+
|
|
518
|
+
const ariaDisabled = Reflect.apply(Reflect.get(element, "getAttribute"), element, [
|
|
519
|
+
"aria-disabled",
|
|
520
|
+
]);
|
|
521
|
+
|
|
522
|
+
const ariaSelected = Reflect.apply(Reflect.get(element, "getAttribute"), element, [
|
|
523
|
+
"aria-selected",
|
|
524
|
+
]);
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
matchCount,
|
|
528
|
+
kind,
|
|
529
|
+
...(typeof checked === "boolean"
|
|
530
|
+
? { checked }
|
|
531
|
+
: ariaChecked === "true" || ariaChecked === "false"
|
|
532
|
+
? { checked: ariaChecked === "true" }
|
|
533
|
+
: {}),
|
|
534
|
+
...(typeof selected === "boolean"
|
|
535
|
+
? { selected }
|
|
536
|
+
: ariaSelected === "true" || ariaSelected === "false"
|
|
537
|
+
? { selected: ariaSelected === "true" }
|
|
538
|
+
: {}),
|
|
539
|
+
disabled: typeof disabled === "boolean" ? disabled : ariaDisabled === "true",
|
|
540
|
+
...(typeof required === "boolean" ? { required } : {}),
|
|
541
|
+
...(validity !== undefined && typeof Reflect.get(validity, "valid") === "boolean"
|
|
542
|
+
? { valid: Reflect.get(validity, "valid") }
|
|
543
|
+
: {}),
|
|
544
|
+
...(typeof formMatches === "function"
|
|
545
|
+
? { formValid: Reflect.apply(formMatches, form, [":valid"]) }
|
|
546
|
+
: {}),
|
|
547
|
+
};
|
|
548
|
+
}, selector);
|
|
549
|
+
|
|
550
|
+
// SDK promises cannot be cancelled. Clear our timer and observe late rejections;
|
|
551
|
+
// the owning browser Scope is responsible for terminating the remote session.
|
|
552
|
+
const boundedBestEffort = async <A>(
|
|
553
|
+
promise: Promise<A>,
|
|
554
|
+
millis: number,
|
|
555
|
+
): Promise<A | undefined> => {
|
|
556
|
+
let timer: ReturnType<typeof setTimeout> | number | undefined;
|
|
557
|
+
|
|
558
|
+
try {
|
|
559
|
+
return await Promise.race([
|
|
560
|
+
promise.catch(() => undefined),
|
|
561
|
+
new Promise<undefined>((resolve) => {
|
|
562
|
+
timer = setTimeout(resolve, millis);
|
|
563
|
+
}),
|
|
564
|
+
]);
|
|
565
|
+
} finally {
|
|
566
|
+
clearTimeout(timer);
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
const readActionTargetAfter = (page: Page, selector: string) =>
|
|
571
|
+
boundedBestEffort(
|
|
572
|
+
readActionTarget(page, selector).then(Schema.decodeUnknownSync(ActionTargetState)),
|
|
573
|
+
ACTION_POST_STATE_MILLIS,
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
const makeActionRequestTracker = (page: Page, signal: AbortSignal) => {
|
|
577
|
+
const pending = new Set<HTTPRequest>();
|
|
578
|
+
let total = 0;
|
|
579
|
+
let status2xx = 0;
|
|
580
|
+
let status3xx = 0;
|
|
581
|
+
let status4xx = 0;
|
|
582
|
+
let status5xx = 0;
|
|
583
|
+
let failed = 0;
|
|
584
|
+
let lastChange = performance.now();
|
|
585
|
+
let closed = false;
|
|
586
|
+
let wake: (() => void) | undefined;
|
|
587
|
+
let timer: ReturnType<typeof setTimeout> | number | undefined;
|
|
588
|
+
|
|
589
|
+
const relevant = (request: HTTPRequest) => {
|
|
590
|
+
const resourceType = request.resourceType();
|
|
591
|
+
|
|
592
|
+
return resourceType === "fetch" || resourceType === "xhr";
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
const onRequest = (request: HTTPRequest) => {
|
|
596
|
+
if (!relevant(request)) return;
|
|
597
|
+
pending.add(request);
|
|
598
|
+
total++;
|
|
599
|
+
lastChange = performance.now();
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
const onFinished = (request: HTTPRequest) => {
|
|
603
|
+
if (!pending.delete(request)) return;
|
|
604
|
+
let status: number | undefined;
|
|
605
|
+
|
|
606
|
+
try {
|
|
607
|
+
status = request.response()?.status();
|
|
608
|
+
} catch {
|
|
609
|
+
// Provider response details remain intentionally unavailable to diagnostics.
|
|
610
|
+
}
|
|
611
|
+
if (status !== undefined) {
|
|
612
|
+
if (status >= 200 && status < 300) status2xx++;
|
|
613
|
+
else if (status >= 300 && status < 400) status3xx++;
|
|
614
|
+
else if (status >= 400 && status < 500) status4xx++;
|
|
615
|
+
else if (status >= 500 && status < 600) status5xx++;
|
|
616
|
+
}
|
|
617
|
+
lastChange = performance.now();
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
const onFailed = (request: HTTPRequest) => {
|
|
621
|
+
if (!pending.delete(request)) return;
|
|
622
|
+
failed++;
|
|
623
|
+
lastChange = performance.now();
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const close = () => {
|
|
627
|
+
if (closed) return;
|
|
628
|
+
closed = true;
|
|
629
|
+
page.off("request", onRequest);
|
|
630
|
+
page.off("requestfinished", onFinished);
|
|
631
|
+
page.off("requestfailed", onFailed);
|
|
632
|
+
signal.removeEventListener("abort", close);
|
|
633
|
+
clearTimeout(timer);
|
|
634
|
+
wake?.();
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
try {
|
|
638
|
+
page.on("request", onRequest);
|
|
639
|
+
page.on("requestfinished", onFinished);
|
|
640
|
+
page.on("requestfailed", onFailed);
|
|
641
|
+
signal.addEventListener("abort", close, { once: true });
|
|
642
|
+
if (signal.aborted) close();
|
|
643
|
+
} catch (cause) {
|
|
644
|
+
close();
|
|
645
|
+
throw cause;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const wait = async () => {
|
|
649
|
+
const startedAt = performance.now();
|
|
650
|
+
let settleTimedOut = false;
|
|
651
|
+
|
|
652
|
+
while (!closed) {
|
|
653
|
+
const now = performance.now();
|
|
654
|
+
|
|
655
|
+
if (pending.size === 0 && now - lastChange >= ACTION_NETWORK_QUIET_MILLIS) break;
|
|
656
|
+
if (now - startedAt >= ACTION_NETWORK_SETTLE_MILLIS) {
|
|
657
|
+
settleTimedOut = true;
|
|
658
|
+
break;
|
|
659
|
+
}
|
|
660
|
+
await new Promise<void>((resolve) => {
|
|
661
|
+
wake = resolve;
|
|
662
|
+
timer = setTimeout(() => resolve(), 50);
|
|
663
|
+
});
|
|
664
|
+
wake = undefined;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return {
|
|
668
|
+
total,
|
|
669
|
+
status2xx,
|
|
670
|
+
status3xx,
|
|
671
|
+
status4xx,
|
|
672
|
+
status5xx,
|
|
673
|
+
failed,
|
|
674
|
+
pending: pending.size,
|
|
675
|
+
settleTimedOut,
|
|
676
|
+
};
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
return {
|
|
680
|
+
wait,
|
|
681
|
+
close,
|
|
682
|
+
markActionSettled: () => {
|
|
683
|
+
lastChange = performance.now();
|
|
684
|
+
},
|
|
685
|
+
};
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
const disposeActionHandles = async (handles: ReadonlyArray<{ dispose: () => Promise<void> }>) => {
|
|
689
|
+
await boundedBestEffort(
|
|
690
|
+
Promise.allSettled(handles.map(async (handle) => handle.dispose())),
|
|
691
|
+
ACTION_POST_STATE_MILLIS,
|
|
692
|
+
);
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
const runObservedPageAction = async (
|
|
696
|
+
page: Page,
|
|
697
|
+
selector: string,
|
|
698
|
+
signal: AbortSignal,
|
|
699
|
+
onDispatch: () => void,
|
|
700
|
+
action: (element: NonNullable<Awaited<ReturnType<Page["$"]>>>) => Promise<void>,
|
|
701
|
+
): Promise<unknown> => {
|
|
702
|
+
if (signal.aborted) throw new BrowserRunActionUndispatched(0);
|
|
703
|
+
|
|
704
|
+
const before = Schema.decodeUnknownSync(ActionTargetState)(
|
|
705
|
+
await readActionTarget(page, selector),
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
if (before.matchCount !== 1 || before.invalidSelector === true) {
|
|
709
|
+
throw new BrowserRunActionUndispatched(before.matchCount);
|
|
710
|
+
}
|
|
711
|
+
const matches = await page.$$(selector);
|
|
712
|
+
|
|
713
|
+
if (matches.length !== 1 || matches[0] === undefined) {
|
|
714
|
+
await disposeActionHandles(matches);
|
|
715
|
+
throw new BrowserRunActionUndispatched(Math.min(10_000, matches.length));
|
|
716
|
+
}
|
|
717
|
+
if (signal.aborted) {
|
|
718
|
+
await disposeActionHandles(matches);
|
|
719
|
+
throw new BrowserRunActionUndispatched(1);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
let tracker: ReturnType<typeof makeActionRequestTracker> | undefined;
|
|
723
|
+
let disposing: Promise<void> | undefined;
|
|
724
|
+
const dispose = () => (disposing ??= disposeActionHandles(matches));
|
|
725
|
+
|
|
726
|
+
const onAbort = () => {
|
|
727
|
+
void dispose();
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
731
|
+
try {
|
|
732
|
+
tracker = makeActionRequestTracker(page, signal);
|
|
733
|
+
// No await between the final cancellation fence and SDK dispatch. Once
|
|
734
|
+
// dispatched, interruption is uncertain, even if the SDK later resolves.
|
|
735
|
+
if (signal.aborted) throw new BrowserRunActionUndispatched(1);
|
|
736
|
+
onDispatch();
|
|
737
|
+
await action(matches[0]);
|
|
738
|
+
tracker.markActionSettled();
|
|
739
|
+
const network = await tracker.wait();
|
|
740
|
+
const after = signal.aborted ? undefined : await readActionTargetAfter(page, selector);
|
|
741
|
+
|
|
742
|
+
return {
|
|
743
|
+
before,
|
|
744
|
+
...(after === undefined ? {} : { after }),
|
|
745
|
+
afterUnavailable: after === undefined,
|
|
746
|
+
network,
|
|
747
|
+
};
|
|
748
|
+
} finally {
|
|
749
|
+
tracker?.close();
|
|
750
|
+
signal.removeEventListener("abort", onAbort);
|
|
751
|
+
await dispose();
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
|
|
756
|
+
const listeners = new Map<BrowserRunInteractiveRequestListener, (request: HTTPRequest) => void>();
|
|
757
|
+
|
|
758
|
+
return {
|
|
759
|
+
close: () => page.close(),
|
|
760
|
+
setBypassServiceWorker: (enabled) => page.setBypassServiceWorker(enabled),
|
|
761
|
+
setRequestInterception: (enabled) => page.setRequestInterception(enabled),
|
|
762
|
+
onRequest: (listener) => {
|
|
763
|
+
const sdkListener = (request: HTTPRequest) => listener(makeProductionRequest(request));
|
|
764
|
+
|
|
765
|
+
listeners.set(listener, sdkListener);
|
|
766
|
+
page.on("request", sdkListener);
|
|
767
|
+
},
|
|
768
|
+
offRequest: (listener) => {
|
|
769
|
+
const sdkListener = listeners.get(listener);
|
|
770
|
+
|
|
771
|
+
if (sdkListener !== undefined) {
|
|
772
|
+
page.off("request", sdkListener);
|
|
773
|
+
listeners.delete(listener);
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
goto: async (url) => {
|
|
777
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
778
|
+
},
|
|
779
|
+
url: () => page.url(),
|
|
780
|
+
readText: async (selector, maximumBytes) => {
|
|
781
|
+
const observation = Schema.decodeUnknownSync(TextObservation)(
|
|
782
|
+
await page.evaluate(
|
|
783
|
+
(requestedSelector, maximum, maximumControls) => {
|
|
784
|
+
const pageDocument = Reflect.get(globalThis, "document");
|
|
785
|
+
|
|
786
|
+
const matches =
|
|
787
|
+
requestedSelector === undefined
|
|
788
|
+
? undefined
|
|
789
|
+
: Reflect.apply(Reflect.get(pageDocument, "querySelectorAll"), pageDocument, [
|
|
790
|
+
requestedSelector,
|
|
791
|
+
]);
|
|
792
|
+
|
|
793
|
+
const selectorMatchCount =
|
|
794
|
+
matches === undefined || matches === null
|
|
795
|
+
? 1
|
|
796
|
+
: Math.min(10_000, Reflect.get(matches, "length"));
|
|
797
|
+
|
|
798
|
+
const element =
|
|
799
|
+
matches === undefined || matches === null
|
|
800
|
+
? Reflect.get(pageDocument, "body")
|
|
801
|
+
: Reflect.get(matches, 0);
|
|
802
|
+
|
|
803
|
+
if (element === null) return { _tag: "MissingElement" };
|
|
804
|
+
if (element === undefined) return { _tag: "MissingElement" };
|
|
805
|
+
const innerText = Reflect.get(element, "innerText");
|
|
806
|
+
const textContent = Reflect.get(element, "textContent");
|
|
807
|
+
|
|
808
|
+
const pageText =
|
|
809
|
+
typeof innerText === "string"
|
|
810
|
+
? innerText
|
|
811
|
+
: typeof textContent === "string"
|
|
812
|
+
? textContent
|
|
813
|
+
: "";
|
|
814
|
+
|
|
815
|
+
const primaryControlSelector =
|
|
816
|
+
'input,select,textarea,label,button,[role="checkbox"],[role="radio"],[role="option"],[role="switch"],[role="tab"],[role="button"]';
|
|
817
|
+
|
|
818
|
+
const optionSelector = "select option";
|
|
819
|
+
const secondaryControlSelector = "a[href]";
|
|
820
|
+
const controlSelector = `${primaryControlSelector},${optionSelector},${secondaryControlSelector}`;
|
|
821
|
+
|
|
822
|
+
const selectorFor = (candidate: object) => {
|
|
823
|
+
const parts: Array<string> = [];
|
|
824
|
+
let current: object | null = candidate;
|
|
825
|
+
|
|
826
|
+
while (current !== null && current !== undefined) {
|
|
827
|
+
const tagName = String(Reflect.get(current, "tagName") ?? "").toLowerCase();
|
|
828
|
+
|
|
829
|
+
if (tagName === "") break;
|
|
830
|
+
const parent: object | null = Reflect.get(current, "parentElement");
|
|
831
|
+
|
|
832
|
+
if (parent === null) {
|
|
833
|
+
parts.push(tagName);
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
836
|
+
let sibling = Reflect.get(current, "previousElementSibling");
|
|
837
|
+
let index = 1;
|
|
838
|
+
|
|
839
|
+
while (sibling !== null && sibling !== undefined) {
|
|
840
|
+
if (String(Reflect.get(sibling, "tagName") ?? "").toLowerCase() === tagName)
|
|
841
|
+
index++;
|
|
842
|
+
sibling = Reflect.get(sibling, "previousElementSibling");
|
|
843
|
+
}
|
|
844
|
+
parts.push(`${tagName}:nth-of-type(${index})`);
|
|
845
|
+
current = parent;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
return parts.reverse().join(" > ");
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
const visible = (candidate: object) => {
|
|
852
|
+
const hidden = Reflect.get(candidate, "hidden");
|
|
853
|
+
|
|
854
|
+
const ariaHidden = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
|
|
855
|
+
"aria-hidden",
|
|
856
|
+
]);
|
|
857
|
+
|
|
858
|
+
const rects = Reflect.apply(Reflect.get(candidate, "getClientRects"), candidate, []);
|
|
859
|
+
|
|
860
|
+
return (
|
|
861
|
+
hidden !== true &&
|
|
862
|
+
ariaHidden !== "true" &&
|
|
863
|
+
typeof rects === "object" &&
|
|
864
|
+
rects !== null &&
|
|
865
|
+
Reflect.get(rects, "length") > 0
|
|
866
|
+
);
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
const candidates: Array<object> = [];
|
|
870
|
+
let controlsTruncated = false;
|
|
871
|
+
|
|
872
|
+
const consider = (candidate: object) => {
|
|
873
|
+
const tagName = String(Reflect.get(candidate, "tagName") ?? "").toLowerCase();
|
|
874
|
+
|
|
875
|
+
// Collapsed native options have no client rects. Their owning
|
|
876
|
+
// select determines visibility; observe text/selection, never value.
|
|
877
|
+
const visibilityTarget =
|
|
878
|
+
tagName === "option"
|
|
879
|
+
? Reflect.apply(Reflect.get(candidate, "closest"), candidate, ["select"])
|
|
880
|
+
: candidate;
|
|
881
|
+
|
|
882
|
+
const actionable = tagName !== "label" || Reflect.get(candidate, "control") !== null;
|
|
883
|
+
|
|
884
|
+
if (
|
|
885
|
+
!actionable ||
|
|
886
|
+
typeof visibilityTarget !== "object" ||
|
|
887
|
+
visibilityTarget === null ||
|
|
888
|
+
!visible(visibilityTarget)
|
|
889
|
+
)
|
|
890
|
+
return;
|
|
891
|
+
candidates.push(candidate);
|
|
892
|
+
if (candidates.length > maximumControls) controlsTruncated = true;
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
const elementMatches = Reflect.get(element, "matches");
|
|
896
|
+
|
|
897
|
+
if (
|
|
898
|
+
typeof elementMatches === "function" &&
|
|
899
|
+
Reflect.apply(elementMatches, element, [controlSelector])
|
|
900
|
+
) {
|
|
901
|
+
consider(element);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const considerSelector = (candidateSelector: string) => {
|
|
905
|
+
const descendants = Reflect.apply(Reflect.get(element, "querySelectorAll"), element, [
|
|
906
|
+
candidateSelector,
|
|
907
|
+
]);
|
|
908
|
+
|
|
909
|
+
if (typeof descendants !== "object" || descendants === null) return;
|
|
910
|
+
const descendantCount = Reflect.get(descendants, "length");
|
|
911
|
+
|
|
912
|
+
for (let index = 0; index < descendantCount && !controlsTruncated; index++) {
|
|
913
|
+
consider(Reflect.get(descendants, index));
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
considerSelector(primaryControlSelector);
|
|
918
|
+
if (!controlsTruncated) considerSelector(optionSelector);
|
|
919
|
+
if (!controlsTruncated) considerSelector(secondaryControlSelector);
|
|
920
|
+
|
|
921
|
+
const controls = candidates.slice(0, maximumControls).map((candidate) => {
|
|
922
|
+
const associated = Reflect.get(candidate, "control") ?? candidate;
|
|
923
|
+
const tagName = String(Reflect.get(candidate, "tagName") ?? "").toLowerCase();
|
|
924
|
+
const inputType = String(Reflect.get(associated, "type") ?? "").toLowerCase();
|
|
925
|
+
|
|
926
|
+
const role = String(
|
|
927
|
+
Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, ["role"]) ?? "",
|
|
928
|
+
).toLowerCase();
|
|
929
|
+
|
|
930
|
+
const ariaLabel = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
|
|
931
|
+
"aria-label",
|
|
932
|
+
]);
|
|
933
|
+
|
|
934
|
+
const candidateText =
|
|
935
|
+
tagName === "textarea" || tagName === "input" || tagName === "select"
|
|
936
|
+
? undefined
|
|
937
|
+
: Reflect.get(candidate, tagName === "option" ? "label" : "innerText");
|
|
938
|
+
|
|
939
|
+
const associatedLabels = Reflect.get(associated, "labels");
|
|
940
|
+
|
|
941
|
+
const associatedLabel =
|
|
942
|
+
associatedLabels !== undefined &&
|
|
943
|
+
associatedLabels !== null &&
|
|
944
|
+
Reflect.get(associatedLabels, "length") > 0
|
|
945
|
+
? Reflect.get(Reflect.get(associatedLabels, 0), "innerText")
|
|
946
|
+
: undefined;
|
|
947
|
+
|
|
948
|
+
const label = String(
|
|
949
|
+
typeof ariaLabel === "string" && ariaLabel !== ""
|
|
950
|
+
? ariaLabel
|
|
951
|
+
: typeof candidateText === "string" && candidateText !== ""
|
|
952
|
+
? candidateText
|
|
953
|
+
: (associatedLabel ?? ""),
|
|
954
|
+
)
|
|
955
|
+
.replace(/\s+/g, " ")
|
|
956
|
+
.trim()
|
|
957
|
+
.slice(0, 200);
|
|
958
|
+
|
|
959
|
+
const checked = Reflect.get(associated, "checked");
|
|
960
|
+
|
|
961
|
+
const selected =
|
|
962
|
+
tagName === "select"
|
|
963
|
+
? Reflect.get(associated, "selectedIndex") >= 0
|
|
964
|
+
: Reflect.get(associated, "selected");
|
|
965
|
+
|
|
966
|
+
const disabled = Reflect.get(associated, "disabled");
|
|
967
|
+
const required = Reflect.get(associated, "required");
|
|
968
|
+
const validity = Reflect.get(associated, "validity");
|
|
969
|
+
const form = Reflect.get(associated, "form");
|
|
970
|
+
|
|
971
|
+
const formMatches =
|
|
972
|
+
form === null || form === undefined ? undefined : Reflect.get(form, "matches");
|
|
973
|
+
|
|
974
|
+
const ariaChecked = Reflect.apply(Reflect.get(candidate, "getAttribute"), candidate, [
|
|
975
|
+
"aria-checked",
|
|
976
|
+
]);
|
|
977
|
+
|
|
978
|
+
const ariaSelected = Reflect.apply(
|
|
979
|
+
Reflect.get(candidate, "getAttribute"),
|
|
980
|
+
candidate,
|
|
981
|
+
["aria-selected"],
|
|
982
|
+
);
|
|
983
|
+
|
|
984
|
+
const ariaDisabled = Reflect.apply(
|
|
985
|
+
Reflect.get(candidate, "getAttribute"),
|
|
986
|
+
candidate,
|
|
987
|
+
["aria-disabled"],
|
|
988
|
+
);
|
|
989
|
+
|
|
990
|
+
return {
|
|
991
|
+
selector: selectorFor(candidate),
|
|
992
|
+
kind:
|
|
993
|
+
tagName === "label"
|
|
994
|
+
? `label:${inputType || "control"}`
|
|
995
|
+
: tagName === "input"
|
|
996
|
+
? `input:${inputType || "text"}`
|
|
997
|
+
: role !== ""
|
|
998
|
+
? `role:${role}`
|
|
999
|
+
: tagName,
|
|
1000
|
+
...(label === "" ? {} : { label }),
|
|
1001
|
+
...(typeof checked === "boolean"
|
|
1002
|
+
? { checked }
|
|
1003
|
+
: ariaChecked === "true" || ariaChecked === "false"
|
|
1004
|
+
? { checked: ariaChecked === "true" }
|
|
1005
|
+
: {}),
|
|
1006
|
+
...(typeof selected === "boolean"
|
|
1007
|
+
? { selected }
|
|
1008
|
+
: ariaSelected === "true" || ariaSelected === "false"
|
|
1009
|
+
? { selected: ariaSelected === "true" }
|
|
1010
|
+
: {}),
|
|
1011
|
+
...(typeof disabled === "boolean"
|
|
1012
|
+
? { disabled }
|
|
1013
|
+
: ariaDisabled === "true" || ariaDisabled === "false"
|
|
1014
|
+
? { disabled: ariaDisabled === "true" }
|
|
1015
|
+
: {}),
|
|
1016
|
+
...(typeof required === "boolean" ? { required } : {}),
|
|
1017
|
+
...(validity !== undefined && typeof Reflect.get(validity, "valid") === "boolean"
|
|
1018
|
+
? { valid: Reflect.get(validity, "valid") }
|
|
1019
|
+
: {}),
|
|
1020
|
+
...(typeof formMatches === "function"
|
|
1021
|
+
? { formValid: Reflect.apply(formMatches, form, [":valid"]) }
|
|
1022
|
+
: {}),
|
|
1023
|
+
};
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
// JSON is the bounded wire representation of this existing text result.
|
|
1027
|
+
// eslint-disable-next-line no-restricted-properties
|
|
1028
|
+
const text = JSON.stringify({
|
|
1029
|
+
pageText,
|
|
1030
|
+
selectorMatchCount,
|
|
1031
|
+
controls,
|
|
1032
|
+
controlsTruncated,
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
const observed = new TextEncoder().encode(text).byteLength;
|
|
1036
|
+
|
|
1037
|
+
return observed > maximum ? { _tag: "OverLimit", observed } : { _tag: "Text", text };
|
|
1038
|
+
},
|
|
1039
|
+
selector,
|
|
1040
|
+
maximumBytes,
|
|
1041
|
+
MAX_OBSERVED_CONTROLS,
|
|
1042
|
+
),
|
|
1043
|
+
);
|
|
1044
|
+
|
|
1045
|
+
if (observation._tag === "Text") Schema.decodeSync(PageObservation)(observation.text);
|
|
1046
|
+
|
|
1047
|
+
return observation;
|
|
1048
|
+
},
|
|
1049
|
+
fill: (selector, value, signal, onDispatch) =>
|
|
1050
|
+
runObservedPageAction(page, selector, signal, onDispatch, (element) =>
|
|
1051
|
+
element.evaluate((element, nextValue) => {
|
|
1052
|
+
// Bypass instance setters so React can detect the change when events fire.
|
|
1053
|
+
let prototype = Reflect.getPrototypeOf(element);
|
|
1054
|
+
let setValue: ((value: string) => void) | undefined;
|
|
1055
|
+
|
|
1056
|
+
while (prototype !== null) {
|
|
1057
|
+
const setter = Reflect.getOwnPropertyDescriptor(prototype, "value")?.set;
|
|
1058
|
+
|
|
1059
|
+
if (typeof setter === "function") {
|
|
1060
|
+
setValue = setter;
|
|
1061
|
+
break;
|
|
1062
|
+
}
|
|
1063
|
+
prototype = Reflect.getPrototypeOf(prototype);
|
|
1064
|
+
}
|
|
1065
|
+
if (setValue === undefined) {
|
|
1066
|
+
throw new Error("The selector did not resolve to a fillable field");
|
|
1067
|
+
}
|
|
1068
|
+
const focus = Reflect.get(element, "focus");
|
|
1069
|
+
|
|
1070
|
+
if (typeof focus === "function") Reflect.apply(focus, element, []);
|
|
1071
|
+
Reflect.apply(setValue, element, [nextValue]);
|
|
1072
|
+
const dispatchEvent = Reflect.get(element, "dispatchEvent");
|
|
1073
|
+
|
|
1074
|
+
if (typeof dispatchEvent === "function") {
|
|
1075
|
+
Reflect.apply(dispatchEvent, element, [new Event("input", { bubbles: true })]);
|
|
1076
|
+
Reflect.apply(dispatchEvent, element, [new Event("change", { bubbles: true })]);
|
|
1077
|
+
}
|
|
1078
|
+
}, value),
|
|
1079
|
+
),
|
|
1080
|
+
click: (selector, signal, onDispatch) =>
|
|
1081
|
+
runObservedPageAction(page, selector, signal, onDispatch, (element) => element.click()),
|
|
1082
|
+
// Puppeteer materializes the complete image before returning. The adapter
|
|
1083
|
+
// validates the 8 MiB Schema ceiling and pass limit immediately afterward.
|
|
1084
|
+
screenshot: (fullPage) => page.screenshot({ type: "png", fullPage }),
|
|
1085
|
+
scroll: (deltaX, deltaY) =>
|
|
1086
|
+
page.evaluate(
|
|
1087
|
+
(x, y) => {
|
|
1088
|
+
const scrollBy = Reflect.get(globalThis, "scrollBy");
|
|
1089
|
+
|
|
1090
|
+
Reflect.apply(scrollBy, globalThis, [{ left: x, top: y, behavior: "instant" }]);
|
|
1091
|
+
},
|
|
1092
|
+
deltaX,
|
|
1093
|
+
deltaY,
|
|
1094
|
+
),
|
|
1095
|
+
createCdpSession: async () => makeProductionCdpSession(await page.createCDPSession()),
|
|
1096
|
+
setViewport: (viewport) => page.setViewport(viewport),
|
|
1097
|
+
};
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1100
|
+
const makeProductionContext = (context: BrowserContext): BrowserRunInteractiveContext => ({
|
|
1101
|
+
newPage: async () => makeProductionPage(await context.newPage()),
|
|
1102
|
+
close: () => context.close(),
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
const makeProductionBrowser = (browser: Browser): BrowserRunInteractiveBrowser => ({
|
|
1106
|
+
createContext: async () => makeProductionContext(await browser.createBrowserContext()),
|
|
1107
|
+
close: () => browser.close(),
|
|
1108
|
+
sessionId: () => browser.sessionId(),
|
|
1109
|
+
isConnected: () => browser.isConnected(),
|
|
1110
|
+
onDisconnected: (listener) => {
|
|
1111
|
+
browser.on("disconnected", listener);
|
|
1112
|
+
},
|
|
1113
|
+
offDisconnected: (listener) => {
|
|
1114
|
+
browser.off("disconnected", listener);
|
|
1115
|
+
},
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
const protocolError = (message: string, cause?: unknown): InteractiveBrowserProtocolError =>
|
|
1119
|
+
InteractiveBrowserProtocolError.make({
|
|
1120
|
+
implementation: browserRunInteractiveImplementation,
|
|
1121
|
+
message,
|
|
1122
|
+
...(cause === undefined ? {} : { cause }),
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
const actionError = (operation: BrowserOperation, cause?: unknown): InteractiveBrowserActionError =>
|
|
1126
|
+
InteractiveBrowserActionError.make({
|
|
1127
|
+
implementation: browserRunInteractiveImplementation,
|
|
1128
|
+
operation,
|
|
1129
|
+
message: `The interactive browser ${operation} operation failed`,
|
|
1130
|
+
...(cause === undefined ? {} : { cause }),
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
const undispatchedActionError = (operation: BrowserOperation): InteractiveBrowserActionError =>
|
|
1134
|
+
InteractiveBrowserActionError.make({
|
|
1135
|
+
implementation: browserRunInteractiveImplementation,
|
|
1136
|
+
operation,
|
|
1137
|
+
message: `The interactive browser ${operation} operation was not dispatched`,
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
/** Recognizes a local pre-dispatch refusal without exposing selector or page content. */
|
|
1141
|
+
export const isBrowserRunUndispatchedActionError = (error: unknown): boolean =>
|
|
1142
|
+
Schema.is(InteractiveBrowserActionError)(error) &&
|
|
1143
|
+
error.implementation.identity === browserRunInteractiveImplementation.identity &&
|
|
1144
|
+
(error.operation === "click" || error.operation === "fill") &&
|
|
1145
|
+
error.message === `The interactive browser ${error.operation} operation was not dispatched`;
|
|
1146
|
+
|
|
1147
|
+
const policyError = (message: string): InteractiveBrowserPolicyDeniedError =>
|
|
1148
|
+
InteractiveBrowserPolicyDeniedError.make({
|
|
1149
|
+
implementation: browserRunInteractiveImplementation,
|
|
1150
|
+
message,
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
const expiredError = (): InteractiveBrowserExpiredError =>
|
|
1154
|
+
InteractiveBrowserExpiredError.make({
|
|
1155
|
+
implementation: browserRunInteractiveImplementation,
|
|
1156
|
+
message: "The remote browser is no longer usable",
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
const causeText = (cause: unknown): string => {
|
|
1160
|
+
if (cause instanceof Error) return cause.message.slice(0, 8_000);
|
|
1161
|
+
|
|
1162
|
+
return String(cause).slice(0, 8_000);
|
|
1163
|
+
};
|
|
1164
|
+
|
|
1165
|
+
const isCapacityRefusal = (cause: unknown): boolean =>
|
|
1166
|
+
/(^|\D)429(\D|$)|browser time limit|capacity|too many concurrent/i.test(causeText(cause));
|
|
1167
|
+
|
|
1168
|
+
const isRemoteClosure = (cause: unknown): boolean =>
|
|
1169
|
+
/target closed|browser.*closed|session.*closed|connection.*closed|not connected|websocket.*closed/i.test(
|
|
1170
|
+
causeText(cause),
|
|
1171
|
+
);
|
|
1172
|
+
|
|
1173
|
+
const snapshotPolicy = Effect.fn("BrowserRunInteractive.snapshotPolicy")(function* (
|
|
1174
|
+
input: InteractiveBrowserPolicy,
|
|
1175
|
+
): Effect.fn.Return<
|
|
1176
|
+
InteractiveBrowserPolicySnapshot,
|
|
1177
|
+
InteractiveBrowserPolicyDeniedError | InteractiveBrowserUnsupportedError
|
|
1178
|
+
> {
|
|
1179
|
+
const decoded = yield* Schema.decodeEffect(InteractiveBrowserPolicy)(input).pipe(
|
|
1180
|
+
Effect.mapError(() => policyError("The interactive browser policy is malformed")),
|
|
1181
|
+
);
|
|
1182
|
+
|
|
1183
|
+
if (decoded.network._tag === "PublicWeb") {
|
|
1184
|
+
return yield* InteractiveBrowserUnsupportedError.make({
|
|
1185
|
+
implementation: browserRunInteractiveImplementation,
|
|
1186
|
+
feature: "policy",
|
|
1187
|
+
message:
|
|
1188
|
+
"Cloudflare Browser Run cannot enforce the PublicWeb network policy for all session traffic",
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
return Object.freeze({
|
|
1193
|
+
network:
|
|
1194
|
+
decoded.network._tag === "ExactHosts"
|
|
1195
|
+
? Object.freeze({
|
|
1196
|
+
_tag: decoded.network._tag,
|
|
1197
|
+
allowedHosts: Object.freeze([...decoded.network.allowedHosts]),
|
|
1198
|
+
})
|
|
1199
|
+
: Object.freeze({ _tag: decoded.network._tag }),
|
|
1200
|
+
maxActions: decoded.maxActions,
|
|
1201
|
+
maxElapsedMillis: decoded.maxElapsedMillis,
|
|
1202
|
+
maxReturnedBytes: decoded.maxReturnedBytes,
|
|
1203
|
+
});
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
const hostAllowed = (policy: InteractiveBrowserPolicySnapshot, value: string): boolean => {
|
|
1207
|
+
if (policy.network._tag === "Unrestricted") return true;
|
|
1208
|
+
try {
|
|
1209
|
+
const url = new URL(value);
|
|
1210
|
+
|
|
1211
|
+
return (
|
|
1212
|
+
url.protocol === "https:" &&
|
|
1213
|
+
url.username === "" &&
|
|
1214
|
+
url.password === "" &&
|
|
1215
|
+
policy.network.allowedHosts.some((host) => host === url.host)
|
|
1216
|
+
);
|
|
1217
|
+
} catch {
|
|
1218
|
+
return false;
|
|
1219
|
+
}
|
|
1220
|
+
};
|
|
1221
|
+
|
|
1222
|
+
const keepAliveMillis = (policy: InteractiveBrowserPolicySnapshot): number =>
|
|
1223
|
+
Math.max(MIN_KEEP_ALIVE_MILLIS, Math.min(MAX_KEEP_ALIVE_MILLIS, policy.maxElapsedMillis));
|
|
1224
|
+
|
|
1225
|
+
const closeLateAcquisition = async <A>(
|
|
1226
|
+
signal: AbortSignal,
|
|
1227
|
+
acquire: () => Promise<A>,
|
|
1228
|
+
close: (acquired: A) => Promise<void>,
|
|
1229
|
+
): Promise<A> => {
|
|
1230
|
+
const acquired = await acquire();
|
|
1231
|
+
|
|
1232
|
+
if (!signal.aborted) return acquired;
|
|
1233
|
+
|
|
1234
|
+
// The SDK does not accept AbortSignal. If an acquisition settles after Effect
|
|
1235
|
+
// has interrupted it, ownership never reaches Scope, so close it here instead.
|
|
1236
|
+
try {
|
|
1237
|
+
await close(acquired);
|
|
1238
|
+
} catch {
|
|
1239
|
+
// No caller remains to observe a late cleanup failure, and provider details
|
|
1240
|
+
// must not escape through an unhandled rejection.
|
|
1241
|
+
}
|
|
1242
|
+
throw new Error("The interrupted browser acquisition completed late");
|
|
1243
|
+
};
|
|
1244
|
+
|
|
1245
|
+
const closeWithWarning = (close: () => Promise<void>, warning: string): Effect.Effect<void> =>
|
|
1246
|
+
Effect.tryPromise({
|
|
1247
|
+
try: close,
|
|
1248
|
+
catch: () => protocolError(warning),
|
|
1249
|
+
}).pipe(
|
|
1250
|
+
Effect.timeoutOrElse({
|
|
1251
|
+
duration: Duration.millis(CLEANUP_STEP_TIMEOUT_MILLIS),
|
|
1252
|
+
orElse: () => Effect.fail(protocolError(warning)),
|
|
1253
|
+
}),
|
|
1254
|
+
Effect.catchCause(() => Effect.logWarning(warning)),
|
|
1255
|
+
);
|
|
1256
|
+
|
|
1257
|
+
const deadlineError = Effect.fn("BrowserRunInteractive.deadlineError")(function* (
|
|
1258
|
+
policy: InteractiveBrowserPolicySnapshot,
|
|
1259
|
+
startedAt: number,
|
|
1260
|
+
) {
|
|
1261
|
+
const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
|
|
1262
|
+
|
|
1263
|
+
return yield* InteractiveBrowserLimitError.make({
|
|
1264
|
+
implementation: browserRunInteractiveImplementation,
|
|
1265
|
+
limit: "elapsed",
|
|
1266
|
+
maximum: policy.maxElapsedMillis,
|
|
1267
|
+
observed: Math.max(policy.maxElapsedMillis, now - startedAt),
|
|
1268
|
+
message: "The browser elapsed-time limit was reached",
|
|
1269
|
+
});
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
const withinDeadline = Effect.fn("BrowserRunInteractive.withinDeadline")(function* <A, E, R>(
|
|
1273
|
+
effect: Effect.Effect<A, E, R>,
|
|
1274
|
+
policy: InteractiveBrowserPolicySnapshot,
|
|
1275
|
+
startedAt: number,
|
|
1276
|
+
onTimeout?: () => void,
|
|
1277
|
+
): Effect.fn.Return<A, E | InteractiveBrowserLimitError, R> {
|
|
1278
|
+
const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
|
|
1279
|
+
const elapsed = Math.max(0, now - startedAt);
|
|
1280
|
+
const remaining = policy.maxElapsedMillis - elapsed;
|
|
1281
|
+
|
|
1282
|
+
if (remaining <= 0) return yield* deadlineError(policy, startedAt);
|
|
1283
|
+
|
|
1284
|
+
return yield* effect.pipe(
|
|
1285
|
+
Effect.timeoutOrElse({
|
|
1286
|
+
duration: Duration.millis(remaining),
|
|
1287
|
+
orElse: () =>
|
|
1288
|
+
Effect.sync(() => onTimeout?.()).pipe(Effect.andThen(deadlineError(policy, startedAt))),
|
|
1289
|
+
}),
|
|
1290
|
+
);
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
interface HandleState {
|
|
1294
|
+
readonly closed: { value: boolean };
|
|
1295
|
+
readonly disconnected: { value: boolean };
|
|
1296
|
+
readonly uncertain: { value: boolean };
|
|
1297
|
+
readonly violation: { value: BrowserFailure | undefined };
|
|
1298
|
+
readonly pendingRequests: Set<Promise<void>>;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
const stateFailure = (state: HandleState): BrowserFailure | undefined => {
|
|
1302
|
+
if (state.violation.value !== undefined) return state.violation.value;
|
|
1303
|
+
if (state.closed.value || state.disconnected.value || state.uncertain.value) {
|
|
1304
|
+
return expiredError();
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
return undefined;
|
|
1308
|
+
};
|
|
1309
|
+
|
|
1310
|
+
interface CloseFailure {
|
|
1311
|
+
readonly error: InteractiveBrowserActionError;
|
|
1312
|
+
readonly warning: string;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
interface CloseEntry {
|
|
1316
|
+
readonly close: Effect.Effect<void, InteractiveBrowserActionError>;
|
|
1317
|
+
readonly warning: string;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
interface HandleRuntime {
|
|
1321
|
+
readonly handle: BrowserHandle;
|
|
1322
|
+
readonly run: <A>(
|
|
1323
|
+
effect: Effect.Effect<A, BrowserFailure>,
|
|
1324
|
+
preflight?: Effect.Effect<void, BrowserFailure>,
|
|
1325
|
+
consumeAction?: boolean,
|
|
1326
|
+
) => Effect.Effect<A, BrowserFailure>;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
class BrowserRunRemoteFailure {
|
|
1330
|
+
readonly cause: unknown;
|
|
1331
|
+
|
|
1332
|
+
constructor(cause: unknown) {
|
|
1333
|
+
this.cause = cause;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
const awaitPendingRequests = (state: HandleState): Effect.Effect<void> =>
|
|
1338
|
+
Effect.suspend(() => {
|
|
1339
|
+
const pending = [...state.pendingRequests];
|
|
1340
|
+
|
|
1341
|
+
return pending.length === 0
|
|
1342
|
+
? Effect.void
|
|
1343
|
+
: Effect.promise(() => Promise.all(pending)).pipe(
|
|
1344
|
+
Effect.asVoid,
|
|
1345
|
+
Effect.andThen(awaitPendingRequests(state)),
|
|
1346
|
+
);
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
const decodeNavigationResult = Effect.fn("BrowserRunInteractive.decodeNavigationResult")(function* (
|
|
1350
|
+
page: BrowserRunInteractivePage,
|
|
1351
|
+
policy: InteractiveBrowserPolicySnapshot,
|
|
1352
|
+
) {
|
|
1353
|
+
const url = yield* Effect.try({
|
|
1354
|
+
try: page.url,
|
|
1355
|
+
catch: (cause) => protocolError("Reading the browser navigation URL failed", cause),
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
const result = yield* Schema.decodeUnknownEffect(BrowserNavigationResult)({
|
|
1359
|
+
url,
|
|
1360
|
+
}).pipe(
|
|
1361
|
+
Effect.mapError((cause) =>
|
|
1362
|
+
protocolError("The browser returned a malformed navigation URL", cause),
|
|
1363
|
+
),
|
|
1364
|
+
);
|
|
1365
|
+
|
|
1366
|
+
if (!hostAllowed(policy, result.url)) {
|
|
1367
|
+
return yield* policyError("The browser returned an off-policy page URL");
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
return result;
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
const decodeActionResult = Effect.fn("BrowserRunInteractive.decodeActionResult")(function* (
|
|
1374
|
+
page: BrowserRunInteractivePage,
|
|
1375
|
+
policy: InteractiveBrowserPolicySnapshot,
|
|
1376
|
+
) {
|
|
1377
|
+
const url = yield* Effect.try({
|
|
1378
|
+
try: page.url,
|
|
1379
|
+
catch: (cause) => protocolError("Reading the browser page URL failed", cause),
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
const result = yield* Schema.decodeUnknownEffect(BrowserActionResult)({ url }).pipe(
|
|
1383
|
+
Effect.mapError((cause) => protocolError("The browser returned a malformed page URL", cause)),
|
|
1384
|
+
);
|
|
1385
|
+
|
|
1386
|
+
if (!hostAllowed(policy, result.url)) {
|
|
1387
|
+
return yield* policyError("The browser returned an off-policy page URL");
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
return result;
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
const makeRequestListener =
|
|
1394
|
+
(
|
|
1395
|
+
policy: InteractiveBrowserPolicySnapshot,
|
|
1396
|
+
state: HandleState,
|
|
1397
|
+
): BrowserRunInteractiveRequestListener =>
|
|
1398
|
+
(request) => {
|
|
1399
|
+
let allowed = false;
|
|
1400
|
+
|
|
1401
|
+
try {
|
|
1402
|
+
allowed = hostAllowed(policy, request.url());
|
|
1403
|
+
} catch {
|
|
1404
|
+
allowed = false;
|
|
1405
|
+
}
|
|
1406
|
+
if (!allowed) {
|
|
1407
|
+
state.violation.value = policyError("The browser requested an off-policy URL");
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
let settlement: Promise<void>;
|
|
1411
|
+
|
|
1412
|
+
try {
|
|
1413
|
+
settlement = allowed ? request.continue() : request.abort();
|
|
1414
|
+
} catch {
|
|
1415
|
+
state.violation.value = protocolError("Resolving an intercepted browser request failed");
|
|
1416
|
+
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
const observed = settlement
|
|
1421
|
+
.catch(() => {
|
|
1422
|
+
state.violation.value = protocolError("Resolving an intercepted browser request failed");
|
|
1423
|
+
})
|
|
1424
|
+
.finally(() => {
|
|
1425
|
+
state.pendingRequests.delete(observed);
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
state.pendingRequests.add(observed);
|
|
1429
|
+
};
|
|
1430
|
+
|
|
1431
|
+
const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
|
|
1432
|
+
page: BrowserRunInteractivePage,
|
|
1433
|
+
policy: InteractiveBrowserPolicySnapshot,
|
|
1434
|
+
startedAt: number,
|
|
1435
|
+
state: HandleState,
|
|
1436
|
+
close: Effect.Effect<void, InteractiveBrowserError>,
|
|
1437
|
+
): Effect.fn.Return<HandleRuntime> {
|
|
1438
|
+
const permits = yield* Semaphore.make(1);
|
|
1439
|
+
const actions = yield* Ref.make(0);
|
|
1440
|
+
|
|
1441
|
+
const remote = <A>(operation: BrowserOperation, evaluate: (signal: AbortSignal) => Promise<A>) =>
|
|
1442
|
+
Effect.tryPromise({
|
|
1443
|
+
try: evaluate,
|
|
1444
|
+
catch: (cause) => new BrowserRunRemoteFailure(cause),
|
|
1445
|
+
}).pipe(
|
|
1446
|
+
Effect.catch(
|
|
1447
|
+
(
|
|
1448
|
+
failure,
|
|
1449
|
+
): Effect.Effect<never, InteractiveBrowserActionError | InteractiveBrowserExpiredError> => {
|
|
1450
|
+
const cause = failure.cause;
|
|
1451
|
+
|
|
1452
|
+
if (cause instanceof BrowserRunActionUndispatched) {
|
|
1453
|
+
return Effect.logInfo("Browser interactive action was not dispatched").pipe(
|
|
1454
|
+
Effect.annotateLogs({
|
|
1455
|
+
"browser.action": operation,
|
|
1456
|
+
"browser.selector_match_count": cause.matchCount,
|
|
1457
|
+
}),
|
|
1458
|
+
Effect.andThen(Effect.fail(undispatchedActionError(operation))),
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
if (state.disconnected.value || isRemoteClosure(cause)) {
|
|
1462
|
+
state.disconnected.value = true;
|
|
1463
|
+
|
|
1464
|
+
return Effect.fail(expiredError());
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
return Effect.fail(actionError(operation, cause));
|
|
1468
|
+
},
|
|
1469
|
+
),
|
|
1470
|
+
);
|
|
1471
|
+
|
|
1472
|
+
const observedAction = (
|
|
1473
|
+
operation: "fill" | "click",
|
|
1474
|
+
evaluate: (signal: AbortSignal, onDispatch: () => void) => Promise<unknown>,
|
|
1475
|
+
) =>
|
|
1476
|
+
Effect.suspend(() => {
|
|
1477
|
+
let dispatched = false;
|
|
1478
|
+
let pending: Promise<unknown> | undefined;
|
|
1479
|
+
|
|
1480
|
+
return remote(operation, (signal) => {
|
|
1481
|
+
pending = evaluate(signal, () => {
|
|
1482
|
+
dispatched = true;
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
return pending;
|
|
1486
|
+
}).pipe(
|
|
1487
|
+
Effect.onInterrupt(() =>
|
|
1488
|
+
Effect.gen(function* () {
|
|
1489
|
+
state.uncertain.value = true;
|
|
1490
|
+
// Retain evidence before Scope teardown, without claiming SDK
|
|
1491
|
+
// cancellation or success. A late completion never makes this replayable.
|
|
1492
|
+
yield* Effect.logWarning("Browser interactive action interrupted").pipe(
|
|
1493
|
+
Effect.annotateLogs({
|
|
1494
|
+
"browser.action": operation,
|
|
1495
|
+
"browser.action_dispatched": dispatched,
|
|
1496
|
+
"browser.action_outcome_unknown": dispatched,
|
|
1497
|
+
}),
|
|
1498
|
+
);
|
|
1499
|
+
const completion = pending;
|
|
1500
|
+
|
|
1501
|
+
if (completion !== undefined) {
|
|
1502
|
+
yield* Effect.promise(() => boundedBestEffort(completion, 500));
|
|
1503
|
+
}
|
|
1504
|
+
}),
|
|
1505
|
+
),
|
|
1506
|
+
);
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
const decodeActionObservation = Effect.fn("BrowserRunInteractive.decodeActionObservation")(
|
|
1510
|
+
function* (raw: unknown) {
|
|
1511
|
+
return yield* Schema.decodeUnknownEffect(ActionObservation)(raw).pipe(
|
|
1512
|
+
Effect.mapError((cause) =>
|
|
1513
|
+
protocolError("The browser returned a malformed action observation", cause),
|
|
1514
|
+
),
|
|
1515
|
+
);
|
|
1516
|
+
},
|
|
1517
|
+
);
|
|
1518
|
+
|
|
1519
|
+
const logActionObservation = (
|
|
1520
|
+
operation: "fill" | "click",
|
|
1521
|
+
observation: typeof ActionObservation.Type,
|
|
1522
|
+
) =>
|
|
1523
|
+
Effect.logInfo("Browser interactive action observed").pipe(
|
|
1524
|
+
Effect.annotateLogs({
|
|
1525
|
+
"browser.action": operation,
|
|
1526
|
+
"browser.selector_match_count": observation.before.matchCount,
|
|
1527
|
+
...(observation.before.kind === undefined
|
|
1528
|
+
? {}
|
|
1529
|
+
: { "browser.target_kind": observation.before.kind }),
|
|
1530
|
+
...(observation.before.checked === undefined
|
|
1531
|
+
? {}
|
|
1532
|
+
: { "browser.target_checked_before": observation.before.checked }),
|
|
1533
|
+
...(observation.after?.checked === undefined
|
|
1534
|
+
? {}
|
|
1535
|
+
: { "browser.target_checked_after": observation.after.checked }),
|
|
1536
|
+
...(observation.before.selected === undefined
|
|
1537
|
+
? {}
|
|
1538
|
+
: { "browser.target_selected_before": observation.before.selected }),
|
|
1539
|
+
...(observation.after?.selected === undefined
|
|
1540
|
+
? {}
|
|
1541
|
+
: { "browser.target_selected_after": observation.after.selected }),
|
|
1542
|
+
...(observation.before.disabled === undefined
|
|
1543
|
+
? {}
|
|
1544
|
+
: { "browser.target_disabled_before": observation.before.disabled }),
|
|
1545
|
+
...(observation.after?.disabled === undefined
|
|
1546
|
+
? {}
|
|
1547
|
+
: { "browser.target_disabled_after": observation.after.disabled }),
|
|
1548
|
+
...(observation.before.required === undefined
|
|
1549
|
+
? {}
|
|
1550
|
+
: { "browser.target_required_before": observation.before.required }),
|
|
1551
|
+
...(observation.after?.required === undefined
|
|
1552
|
+
? {}
|
|
1553
|
+
: { "browser.target_required_after": observation.after.required }),
|
|
1554
|
+
...(observation.before.valid === undefined
|
|
1555
|
+
? {}
|
|
1556
|
+
: { "browser.target_valid_before": observation.before.valid }),
|
|
1557
|
+
...(observation.after?.valid === undefined
|
|
1558
|
+
? {}
|
|
1559
|
+
: { "browser.target_valid_after": observation.after.valid }),
|
|
1560
|
+
...(observation.before.formValid === undefined
|
|
1561
|
+
? {}
|
|
1562
|
+
: { "browser.form_valid_before": observation.before.formValid }),
|
|
1563
|
+
...(observation.after?.formValid === undefined
|
|
1564
|
+
? {}
|
|
1565
|
+
: { "browser.form_valid_after": observation.after.formValid }),
|
|
1566
|
+
"browser.target_after_unavailable": observation.afterUnavailable,
|
|
1567
|
+
"browser.fetch_xhr_total": observation.network.total,
|
|
1568
|
+
"browser.fetch_xhr_2xx": observation.network.status2xx,
|
|
1569
|
+
"browser.fetch_xhr_3xx": observation.network.status3xx,
|
|
1570
|
+
"browser.fetch_xhr_4xx": observation.network.status4xx,
|
|
1571
|
+
"browser.fetch_xhr_5xx": observation.network.status5xx,
|
|
1572
|
+
"browser.fetch_xhr_failed": observation.network.failed,
|
|
1573
|
+
"browser.fetch_xhr_pending": observation.network.pending,
|
|
1574
|
+
"browser.network_settle_timed_out": observation.network.settleTimedOut,
|
|
1575
|
+
}),
|
|
1576
|
+
);
|
|
1577
|
+
|
|
1578
|
+
const run = <A>(
|
|
1579
|
+
effect: Effect.Effect<A, BrowserFailure>,
|
|
1580
|
+
preflight: Effect.Effect<void, BrowserFailure> = Effect.void,
|
|
1581
|
+
consumeAction = true,
|
|
1582
|
+
) =>
|
|
1583
|
+
permits
|
|
1584
|
+
.withPermitsIfAvailable(1)(
|
|
1585
|
+
Effect.gen(function* () {
|
|
1586
|
+
const unavailable = stateFailure(state);
|
|
1587
|
+
|
|
1588
|
+
if (unavailable !== undefined) return yield* unavailable;
|
|
1589
|
+
yield* preflight;
|
|
1590
|
+
|
|
1591
|
+
if (consumeAction) {
|
|
1592
|
+
const admitted = yield* Ref.modify(actions, (count) =>
|
|
1593
|
+
count >= policy.maxActions
|
|
1594
|
+
? [{ allowed: false, observed: count + 1 }, count]
|
|
1595
|
+
: [{ allowed: true, observed: count + 1 }, count + 1],
|
|
1596
|
+
);
|
|
1597
|
+
|
|
1598
|
+
if (!admitted.allowed) {
|
|
1599
|
+
return yield* InteractiveBrowserLimitError.make({
|
|
1600
|
+
implementation: browserRunInteractiveImplementation,
|
|
1601
|
+
limit: "actions",
|
|
1602
|
+
maximum: policy.maxActions,
|
|
1603
|
+
observed: admitted.observed,
|
|
1604
|
+
message: "The browser action limit was reached",
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
const completed = effect.pipe(
|
|
1610
|
+
Effect.catch((error) => {
|
|
1611
|
+
const failure = stateFailure(state);
|
|
1612
|
+
|
|
1613
|
+
return Effect.fail(failure ?? error);
|
|
1614
|
+
}),
|
|
1615
|
+
Effect.flatMap((result) =>
|
|
1616
|
+
awaitPendingRequests(state).pipe(
|
|
1617
|
+
Effect.flatMap(() => {
|
|
1618
|
+
const failure = stateFailure(state);
|
|
1619
|
+
|
|
1620
|
+
return failure === undefined ? Effect.succeed(result) : Effect.fail(failure);
|
|
1621
|
+
}),
|
|
1622
|
+
),
|
|
1623
|
+
),
|
|
1624
|
+
);
|
|
1625
|
+
|
|
1626
|
+
return yield* withinDeadline(completed, policy, startedAt, () => {
|
|
1627
|
+
state.uncertain.value = true;
|
|
1628
|
+
}).pipe(
|
|
1629
|
+
Effect.onInterrupt(() =>
|
|
1630
|
+
Effect.sync(() => {
|
|
1631
|
+
state.uncertain.value = true;
|
|
1632
|
+
}),
|
|
1633
|
+
),
|
|
1634
|
+
Effect.catch((error) => {
|
|
1635
|
+
if (Schema.is(InteractiveBrowserLimitError)(error) && error.limit === "elapsed") {
|
|
1636
|
+
return Effect.fail(error);
|
|
1637
|
+
}
|
|
1638
|
+
const failure = stateFailure(state);
|
|
1639
|
+
|
|
1640
|
+
if (
|
|
1641
|
+
Schema.is(InteractiveBrowserActionError)(error) &&
|
|
1642
|
+
!isBrowserRunUndispatchedActionError(error)
|
|
1643
|
+
) {
|
|
1644
|
+
state.uncertain.value = true;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
return Effect.fail(failure ?? error);
|
|
1648
|
+
}),
|
|
1649
|
+
);
|
|
1650
|
+
}),
|
|
1651
|
+
)
|
|
1652
|
+
.pipe(
|
|
1653
|
+
Effect.flatMap(
|
|
1654
|
+
Effect.fromOption(() =>
|
|
1655
|
+
InteractiveBrowserBusyError.make({
|
|
1656
|
+
implementation: browserRunInteractiveImplementation,
|
|
1657
|
+
message: "The browser handle already has an operation in flight",
|
|
1658
|
+
}),
|
|
1659
|
+
),
|
|
1660
|
+
),
|
|
1661
|
+
);
|
|
1662
|
+
|
|
1663
|
+
const handle: BrowserHandle = {
|
|
1664
|
+
navigate: (request) =>
|
|
1665
|
+
run(
|
|
1666
|
+
Effect.gen(function* () {
|
|
1667
|
+
yield* remote("navigate", () => page.goto(request.url));
|
|
1668
|
+
|
|
1669
|
+
return yield* decodeNavigationResult(page, policy);
|
|
1670
|
+
}),
|
|
1671
|
+
Effect.suspend(() =>
|
|
1672
|
+
Schema.is(InteractiveBrowserTargetUrl)(request.url) && hostAllowed(policy, request.url)
|
|
1673
|
+
? Effect.void
|
|
1674
|
+
: Effect.fail(policyError("The navigation URL is outside the browser policy")),
|
|
1675
|
+
),
|
|
1676
|
+
),
|
|
1677
|
+
readText: (request) =>
|
|
1678
|
+
run(
|
|
1679
|
+
Effect.gen(function* () {
|
|
1680
|
+
const raw = yield* remote("read-text", () =>
|
|
1681
|
+
page.readText(request.selector, policy.maxReturnedBytes),
|
|
1682
|
+
);
|
|
1683
|
+
|
|
1684
|
+
const observation = yield* Schema.decodeUnknownEffect(TextObservation)(raw).pipe(
|
|
1685
|
+
Effect.mapError((cause) =>
|
|
1686
|
+
protocolError("The browser returned a malformed text observation", cause),
|
|
1687
|
+
),
|
|
1688
|
+
);
|
|
1689
|
+
|
|
1690
|
+
if (observation._tag === "MissingElement") {
|
|
1691
|
+
return yield* actionError("read-text");
|
|
1692
|
+
}
|
|
1693
|
+
if (observation._tag === "OverLimit") {
|
|
1694
|
+
return yield* InteractiveBrowserLimitError.make({
|
|
1695
|
+
implementation: browserRunInteractiveImplementation,
|
|
1696
|
+
limit: "returned-bytes",
|
|
1697
|
+
maximum: policy.maxReturnedBytes,
|
|
1698
|
+
observed: observation.observed,
|
|
1699
|
+
message: "The browser returned-text limit was reached",
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
const observed = new TextEncoder().encode(observation.text).byteLength;
|
|
1703
|
+
|
|
1704
|
+
if (observed > policy.maxReturnedBytes) {
|
|
1705
|
+
return yield* InteractiveBrowserLimitError.make({
|
|
1706
|
+
implementation: browserRunInteractiveImplementation,
|
|
1707
|
+
limit: "returned-bytes",
|
|
1708
|
+
maximum: policy.maxReturnedBytes,
|
|
1709
|
+
observed,
|
|
1710
|
+
message: "The browser returned-text limit was reached",
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
return yield* Schema.decodeEffect(BrowserTextResult)({
|
|
1715
|
+
text: observation.text,
|
|
1716
|
+
}).pipe(
|
|
1717
|
+
Effect.mapError((cause) =>
|
|
1718
|
+
protocolError("The browser returned malformed page text", cause),
|
|
1719
|
+
),
|
|
1720
|
+
);
|
|
1721
|
+
}),
|
|
1722
|
+
),
|
|
1723
|
+
fill: (request) =>
|
|
1724
|
+
run(
|
|
1725
|
+
Effect.gen(function* () {
|
|
1726
|
+
const observation = yield* observedAction("fill", (signal, onDispatch) =>
|
|
1727
|
+
page.fill(request.selector, request.value, signal, onDispatch),
|
|
1728
|
+
).pipe(Effect.flatMap(decodeActionObservation));
|
|
1729
|
+
|
|
1730
|
+
yield* logActionObservation("fill", observation);
|
|
1731
|
+
|
|
1732
|
+
return yield* decodeActionResult(page, policy);
|
|
1733
|
+
}),
|
|
1734
|
+
),
|
|
1735
|
+
click: (request) =>
|
|
1736
|
+
run(
|
|
1737
|
+
Effect.gen(function* () {
|
|
1738
|
+
const observation = yield* observedAction("click", (signal, onDispatch) =>
|
|
1739
|
+
page.click(request.selector, signal, onDispatch),
|
|
1740
|
+
).pipe(Effect.flatMap(decodeActionObservation));
|
|
1741
|
+
|
|
1742
|
+
yield* logActionObservation("click", observation);
|
|
1743
|
+
|
|
1744
|
+
return yield* decodeActionResult(page, policy);
|
|
1745
|
+
}),
|
|
1746
|
+
),
|
|
1747
|
+
screenshot: (request) =>
|
|
1748
|
+
Schema.decodeEffect(BrowserScreenshotRequest)(request).pipe(
|
|
1749
|
+
Effect.mapError(() => policyError("The browser screenshot request is malformed")),
|
|
1750
|
+
Effect.flatMap((decoded) =>
|
|
1751
|
+
run(
|
|
1752
|
+
Effect.gen(function* () {
|
|
1753
|
+
const raw = yield* remote("screenshot", () => page.screenshot(decoded.fullPage));
|
|
1754
|
+
|
|
1755
|
+
const bytes = yield* Schema.decodeUnknownEffect(PngBytes)(raw).pipe(
|
|
1756
|
+
Effect.mapError(() =>
|
|
1757
|
+
protocolError("The browser returned a malformed PNG screenshot"),
|
|
1758
|
+
),
|
|
1759
|
+
);
|
|
1760
|
+
|
|
1761
|
+
if (bytes.length > policy.maxReturnedBytes) {
|
|
1762
|
+
return yield* InteractiveBrowserLimitError.make({
|
|
1763
|
+
implementation: browserRunInteractiveImplementation,
|
|
1764
|
+
limit: "returned-bytes",
|
|
1765
|
+
maximum: policy.maxReturnedBytes,
|
|
1766
|
+
observed: bytes.length,
|
|
1767
|
+
message: "The browser screenshot byte limit was reached",
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
return yield* Schema.decodeEffect(PageScreenshotResult)({
|
|
1772
|
+
implementation: browserRunInteractiveImplementation,
|
|
1773
|
+
mediaType: "image/png",
|
|
1774
|
+
bytes: new Uint8Array(bytes),
|
|
1775
|
+
}).pipe(
|
|
1776
|
+
Effect.mapError(() =>
|
|
1777
|
+
protocolError("The browser returned a malformed PNG screenshot"),
|
|
1778
|
+
),
|
|
1779
|
+
);
|
|
1780
|
+
}),
|
|
1781
|
+
decodeActionResult(page, policy).pipe(Effect.asVoid),
|
|
1782
|
+
),
|
|
1783
|
+
),
|
|
1784
|
+
),
|
|
1785
|
+
scroll: (request) =>
|
|
1786
|
+
Schema.decodeEffect(BrowserScrollRequest)(request).pipe(
|
|
1787
|
+
Effect.mapError(() => policyError("The browser scroll request is malformed")),
|
|
1788
|
+
Effect.flatMap((decoded) =>
|
|
1789
|
+
run(
|
|
1790
|
+
remote("scroll", () => page.scroll(decoded.deltaX, decoded.deltaY)).pipe(
|
|
1791
|
+
Effect.andThen(decodeActionResult(page, policy)),
|
|
1792
|
+
),
|
|
1793
|
+
decodeActionResult(page, policy).pipe(Effect.asVoid),
|
|
1794
|
+
),
|
|
1795
|
+
),
|
|
1796
|
+
),
|
|
1797
|
+
close,
|
|
1798
|
+
};
|
|
1799
|
+
|
|
1800
|
+
return { handle, run };
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
const remainingMillis = Effect.fn("BrowserRunInteractive.remainingMillis")(function* (
|
|
1804
|
+
policy: InteractiveBrowserPolicySnapshot,
|
|
1805
|
+
startedAt: number,
|
|
1806
|
+
) {
|
|
1807
|
+
const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
|
|
1808
|
+
|
|
1809
|
+
return Math.max(0, policy.maxElapsedMillis - Math.max(0, now - startedAt));
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
const closeEntry = (close: () => Promise<void>, warning: string): CloseEntry => ({
|
|
1813
|
+
close: Effect.tryPromise({
|
|
1814
|
+
try: close,
|
|
1815
|
+
catch: (cause) => actionError("close", cause),
|
|
1816
|
+
}).pipe(
|
|
1817
|
+
Effect.timeoutOrElse({
|
|
1818
|
+
duration: Duration.millis(CLEANUP_STEP_TIMEOUT_MILLIS),
|
|
1819
|
+
orElse: () => Effect.fail(actionError("close")),
|
|
1820
|
+
}),
|
|
1821
|
+
),
|
|
1822
|
+
warning,
|
|
1823
|
+
});
|
|
1824
|
+
|
|
1825
|
+
const syncCloseEntry = (close: () => void, warning: string): CloseEntry => ({
|
|
1826
|
+
close: Effect.try({
|
|
1827
|
+
try: close,
|
|
1828
|
+
catch: (cause) => actionError("close", cause),
|
|
1829
|
+
}),
|
|
1830
|
+
warning,
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
const runTeardown = (
|
|
1834
|
+
entries: ReadonlyArray<CloseEntry>,
|
|
1835
|
+
): Effect.Effect<ReadonlyArray<CloseFailure>> =>
|
|
1836
|
+
Effect.forEach([...entries].reverse(), (entry) =>
|
|
1837
|
+
entry.close.pipe(
|
|
1838
|
+
Effect.match({
|
|
1839
|
+
onFailure: (error): CloseFailure | undefined => ({ error, warning: entry.warning }),
|
|
1840
|
+
onSuccess: (): CloseFailure | undefined => undefined,
|
|
1841
|
+
}),
|
|
1842
|
+
),
|
|
1843
|
+
).pipe(Effect.map((failures) => failures.filter((failure) => failure !== undefined)));
|
|
1844
|
+
|
|
1845
|
+
const cdpCommand = <A>(
|
|
1846
|
+
page: BrowserRunInteractivePage,
|
|
1847
|
+
state: HandleState,
|
|
1848
|
+
command: BrowserRunCloudflareCommand,
|
|
1849
|
+
parameters: unknown,
|
|
1850
|
+
output: Schema.Codec<A>,
|
|
1851
|
+
malformedMessage: string,
|
|
1852
|
+
): Effect.Effect<A, InteractiveBrowserError> =>
|
|
1853
|
+
Effect.scoped(
|
|
1854
|
+
Effect.gen(function* () {
|
|
1855
|
+
const cdp = yield* Effect.acquireRelease(
|
|
1856
|
+
Effect.tryPromise({
|
|
1857
|
+
try: (signal) =>
|
|
1858
|
+
closeLateAcquisition(signal, page.createCdpSession, (acquired) => acquired.detach()),
|
|
1859
|
+
catch: (cause) =>
|
|
1860
|
+
state.disconnected.value || isRemoteClosure(cause)
|
|
1861
|
+
? expiredError()
|
|
1862
|
+
: protocolError("Creating the Cloudflare browser control session failed", cause),
|
|
1863
|
+
}),
|
|
1864
|
+
(acquired) =>
|
|
1865
|
+
closeWithWarning(
|
|
1866
|
+
acquired.detach,
|
|
1867
|
+
"Detaching the Cloudflare browser control session failed",
|
|
1868
|
+
),
|
|
1869
|
+
{ interruptible: true },
|
|
1870
|
+
);
|
|
1871
|
+
|
|
1872
|
+
const raw = yield* Effect.tryPromise({
|
|
1873
|
+
try: () => cdp.send(command, parameters),
|
|
1874
|
+
catch: (cause) =>
|
|
1875
|
+
state.disconnected.value || isRemoteClosure(cause)
|
|
1876
|
+
? expiredError()
|
|
1877
|
+
: protocolError("The Cloudflare browser control command failed", cause),
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
return yield* Schema.decodeUnknownEffect(output)(raw).pipe(
|
|
1881
|
+
Effect.mapError(() => protocolError(malformedMessage)),
|
|
1882
|
+
);
|
|
1883
|
+
}),
|
|
1884
|
+
);
|
|
1885
|
+
|
|
1886
|
+
const makeHostService = (
|
|
1887
|
+
binding: BrowserRunInteractiveBinding["Service"],
|
|
1888
|
+
): BrowserRunInteractiveHost["Service"] => {
|
|
1889
|
+
const closeSession = binding.closeSession;
|
|
1890
|
+
|
|
1891
|
+
const terminate = Effect.fn("BrowserRunInteractiveHost.terminate")(function* (
|
|
1892
|
+
sessionId: Redacted.Redacted<string>,
|
|
1893
|
+
entries: ReadonlyArray<CloseEntry>,
|
|
1894
|
+
) {
|
|
1895
|
+
const deadline = (yield* Clock.currentTimeMillis) + 10_000;
|
|
1896
|
+
|
|
1897
|
+
yield* closeSession(sessionId).pipe(
|
|
1898
|
+
Effect.interruptible,
|
|
1899
|
+
Effect.timeoutOrElse({
|
|
1900
|
+
duration: "10 seconds",
|
|
1901
|
+
orElse: () => Effect.fail(actionError("close")),
|
|
1902
|
+
}),
|
|
1903
|
+
);
|
|
1904
|
+
const remaining = deadline - (yield* Clock.currentTimeMillis);
|
|
1905
|
+
|
|
1906
|
+
if (remaining <= 0)
|
|
1907
|
+
return yield* Effect.logWarning(
|
|
1908
|
+
"Local browser teardown skipped after confirmed termination deadline",
|
|
1909
|
+
);
|
|
1910
|
+
// Remote termination is authoritative. Local cleanup must not veto it or extend the deadline.
|
|
1911
|
+
yield* runTeardown(entries).pipe(
|
|
1912
|
+
Effect.flatMap((failures) =>
|
|
1913
|
+
Effect.forEach(failures, (failure) => Effect.logWarning(failure.warning), {
|
|
1914
|
+
discard: true,
|
|
1915
|
+
}),
|
|
1916
|
+
),
|
|
1917
|
+
Effect.interruptible,
|
|
1918
|
+
Effect.timeout(`${remaining} millis`),
|
|
1919
|
+
Effect.catchCause(() =>
|
|
1920
|
+
Effect.logWarning("Local browser teardown incomplete after confirmed termination"),
|
|
1921
|
+
),
|
|
1922
|
+
);
|
|
1923
|
+
});
|
|
1924
|
+
|
|
1925
|
+
const closeAcquired = Effect.fn("BrowserRunInteractiveHost.closeAcquired")(function* (
|
|
1926
|
+
browser: BrowserRunInteractiveBrowser,
|
|
1927
|
+
) {
|
|
1928
|
+
const sessionId = yield* Effect.try({
|
|
1929
|
+
try: browser.sessionId,
|
|
1930
|
+
catch: () => actionError("close"),
|
|
1931
|
+
}).pipe(
|
|
1932
|
+
Effect.flatMap(Schema.decodeUnknownEffect(BrowserRunSessionId)),
|
|
1933
|
+
Effect.mapError(() => actionError("close")),
|
|
1934
|
+
Effect.onError(() =>
|
|
1935
|
+
closeWithWarning(browser.close, "Closing an unidentified browser failed"),
|
|
1936
|
+
),
|
|
1937
|
+
);
|
|
1938
|
+
|
|
1939
|
+
yield* terminate(Redacted.make(sessionId), [
|
|
1940
|
+
closeEntry(browser.close, "Closing the local browser connection failed"),
|
|
1941
|
+
]);
|
|
1942
|
+
});
|
|
1943
|
+
|
|
1944
|
+
const open = Effect.fn("BrowserRunInteractiveHost.open")(function* (
|
|
1945
|
+
policy: InteractiveBrowserPolicy,
|
|
1946
|
+
): Effect.fn.Return<BrowserRunInteractiveSession, InteractiveBrowserError, Scope.Scope> {
|
|
1947
|
+
const fixedPolicy = yield* snapshotPolicy(policy);
|
|
1948
|
+
const startedAt = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
|
|
1949
|
+
// Late SDK replies outlive the opening fiber; cleanup retains this pass's clock.
|
|
1950
|
+
const runCleanup = Effect.runPromiseWith(Context.make(Clock.Clock, yield* Clock.Clock));
|
|
1951
|
+
|
|
1952
|
+
const state: HandleState = {
|
|
1953
|
+
closed: { value: false },
|
|
1954
|
+
disconnected: { value: false },
|
|
1955
|
+
uncertain: { value: false },
|
|
1956
|
+
violation: { value: undefined },
|
|
1957
|
+
pendingRequests: new Set(),
|
|
1958
|
+
};
|
|
1959
|
+
|
|
1960
|
+
const lifecycle = {
|
|
1961
|
+
managedTeardownInstalled: false,
|
|
1962
|
+
};
|
|
1963
|
+
|
|
1964
|
+
const closers: Array<CloseEntry> = [];
|
|
1965
|
+
|
|
1966
|
+
const releaseBeforeManaged = (entry: CloseEntry): Effect.Effect<void> =>
|
|
1967
|
+
Effect.suspend(() =>
|
|
1968
|
+
lifecycle.managedTeardownInstalled
|
|
1969
|
+
? Effect.void
|
|
1970
|
+
: entry.close.pipe(Effect.catchCause(() => Effect.logWarning(entry.warning))),
|
|
1971
|
+
);
|
|
1972
|
+
|
|
1973
|
+
const browser = yield* Effect.acquireRelease(
|
|
1974
|
+
withinDeadline(
|
|
1975
|
+
Effect.tryPromise({
|
|
1976
|
+
try: (signal) =>
|
|
1977
|
+
closeLateAcquisition(
|
|
1978
|
+
signal,
|
|
1979
|
+
() => binding.launch(keepAliveMillis(fixedPolicy)),
|
|
1980
|
+
(acquired) => runCleanup(closeAcquired(acquired)),
|
|
1981
|
+
),
|
|
1982
|
+
catch: (cause) =>
|
|
1983
|
+
isCapacityRefusal(cause)
|
|
1984
|
+
? InteractiveBrowserCapacityError.make({
|
|
1985
|
+
implementation: browserRunInteractiveImplementation,
|
|
1986
|
+
message: "Browser Run has no capacity for a new browser session",
|
|
1987
|
+
})
|
|
1988
|
+
: protocolError("Launching the Browser Run session failed", cause),
|
|
1989
|
+
}),
|
|
1990
|
+
fixedPolicy,
|
|
1991
|
+
startedAt,
|
|
1992
|
+
),
|
|
1993
|
+
(acquired) => {
|
|
1994
|
+
state.disconnected.value = true;
|
|
1995
|
+
|
|
1996
|
+
return lifecycle.managedTeardownInstalled
|
|
1997
|
+
? Effect.void
|
|
1998
|
+
: closeAcquired(acquired).pipe(
|
|
1999
|
+
Effect.catch(() => Effect.logWarning("Whole-browser cleanup remains unconfirmed")),
|
|
2000
|
+
);
|
|
2001
|
+
},
|
|
2002
|
+
{ interruptible: true },
|
|
2003
|
+
);
|
|
2004
|
+
|
|
2005
|
+
closers.push(closeEntry(browser.close, "Closing the interactive browser failed"));
|
|
2006
|
+
|
|
2007
|
+
const disconnected = () => {
|
|
2008
|
+
state.disconnected.value = true;
|
|
2009
|
+
};
|
|
2010
|
+
|
|
2011
|
+
yield* Effect.acquireRelease(
|
|
2012
|
+
Effect.try({
|
|
2013
|
+
try: () => browser.onDisconnected(disconnected),
|
|
2014
|
+
catch: (cause) => protocolError("Installing the browser disconnect listener failed", cause),
|
|
2015
|
+
}),
|
|
2016
|
+
() =>
|
|
2017
|
+
releaseBeforeManaged(
|
|
2018
|
+
syncCloseEntry(
|
|
2019
|
+
() => browser.offDisconnected(disconnected),
|
|
2020
|
+
"Removing the browser disconnect listener failed",
|
|
2021
|
+
),
|
|
2022
|
+
),
|
|
2023
|
+
);
|
|
2024
|
+
closers.push(
|
|
2025
|
+
syncCloseEntry(
|
|
2026
|
+
() => browser.offDisconnected(disconnected),
|
|
2027
|
+
"Removing the browser disconnect listener failed",
|
|
2028
|
+
),
|
|
2029
|
+
);
|
|
2030
|
+
|
|
2031
|
+
const connected = yield* Effect.try({
|
|
2032
|
+
try: browser.isConnected,
|
|
2033
|
+
catch: (cause) => protocolError("Reading the Browser Run connection state failed", cause),
|
|
2034
|
+
});
|
|
2035
|
+
|
|
2036
|
+
if (!connected) {
|
|
2037
|
+
state.disconnected.value = true;
|
|
2038
|
+
|
|
2039
|
+
return yield* expiredError();
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
const sessionIdValue = yield* Effect.try({
|
|
2043
|
+
try: browser.sessionId,
|
|
2044
|
+
catch: (cause) => protocolError("Reading the Browser Run session identity failed", cause),
|
|
2045
|
+
}).pipe(
|
|
2046
|
+
Effect.flatMap((value) =>
|
|
2047
|
+
Schema.decodeUnknownEffect(BrowserRunSessionId)(value).pipe(
|
|
2048
|
+
Effect.mapError(() => protocolError("The Browser Run session identity was malformed")),
|
|
2049
|
+
),
|
|
2050
|
+
),
|
|
2051
|
+
);
|
|
2052
|
+
|
|
2053
|
+
const context = yield* Effect.acquireRelease(
|
|
2054
|
+
withinDeadline(
|
|
2055
|
+
Effect.tryPromise({
|
|
2056
|
+
try: (signal) =>
|
|
2057
|
+
closeLateAcquisition(signal, browser.createContext, (acquired) => acquired.close()),
|
|
2058
|
+
catch: (cause) =>
|
|
2059
|
+
state.disconnected.value || isRemoteClosure(cause)
|
|
2060
|
+
? expiredError()
|
|
2061
|
+
: protocolError("Creating the browser context failed", cause),
|
|
2062
|
+
}),
|
|
2063
|
+
fixedPolicy,
|
|
2064
|
+
startedAt,
|
|
2065
|
+
),
|
|
2066
|
+
(acquired) =>
|
|
2067
|
+
releaseBeforeManaged(
|
|
2068
|
+
closeEntry(acquired.close, "Closing the interactive browser context failed"),
|
|
2069
|
+
),
|
|
2070
|
+
{ interruptible: true },
|
|
2071
|
+
);
|
|
2072
|
+
|
|
2073
|
+
closers.push(closeEntry(context.close, "Closing the interactive browser context failed"));
|
|
2074
|
+
|
|
2075
|
+
const page = yield* Effect.acquireRelease(
|
|
2076
|
+
withinDeadline(
|
|
2077
|
+
Effect.tryPromise({
|
|
2078
|
+
try: (signal) =>
|
|
2079
|
+
closeLateAcquisition(signal, context.newPage, (acquired) => acquired.close()),
|
|
2080
|
+
catch: (cause) =>
|
|
2081
|
+
state.disconnected.value || isRemoteClosure(cause)
|
|
2082
|
+
? expiredError()
|
|
2083
|
+
: protocolError("Creating the browser page failed", cause),
|
|
2084
|
+
}),
|
|
2085
|
+
fixedPolicy,
|
|
2086
|
+
startedAt,
|
|
2087
|
+
),
|
|
2088
|
+
(acquired) =>
|
|
2089
|
+
releaseBeforeManaged(
|
|
2090
|
+
closeEntry(acquired.close, "Closing the interactive browser page failed"),
|
|
2091
|
+
),
|
|
2092
|
+
{ interruptible: true },
|
|
2093
|
+
);
|
|
2094
|
+
|
|
2095
|
+
closers.push(closeEntry(page.close, "Closing the interactive browser page failed"));
|
|
2096
|
+
|
|
2097
|
+
yield* withinDeadline(
|
|
2098
|
+
Effect.tryPromise({
|
|
2099
|
+
try: () => page.setBypassServiceWorker(true),
|
|
2100
|
+
catch: (cause) => protocolError("Bypassing browser service workers failed", cause),
|
|
2101
|
+
}),
|
|
2102
|
+
fixedPolicy,
|
|
2103
|
+
startedAt,
|
|
2104
|
+
);
|
|
2105
|
+
|
|
2106
|
+
const requestListener = makeRequestListener(fixedPolicy, state);
|
|
2107
|
+
|
|
2108
|
+
yield* Effect.acquireRelease(
|
|
2109
|
+
Effect.try({
|
|
2110
|
+
try: () => page.onRequest(requestListener),
|
|
2111
|
+
catch: (cause) => protocolError("Installing the browser request listener failed", cause),
|
|
2112
|
+
}),
|
|
2113
|
+
() =>
|
|
2114
|
+
releaseBeforeManaged(
|
|
2115
|
+
syncCloseEntry(
|
|
2116
|
+
() => page.offRequest(requestListener),
|
|
2117
|
+
"Removing the browser request policy failed",
|
|
2118
|
+
),
|
|
2119
|
+
),
|
|
2120
|
+
);
|
|
2121
|
+
closers.push(
|
|
2122
|
+
syncCloseEntry(
|
|
2123
|
+
() => page.offRequest(requestListener),
|
|
2124
|
+
"Removing the browser request policy failed",
|
|
2125
|
+
),
|
|
2126
|
+
);
|
|
2127
|
+
|
|
2128
|
+
yield* withinDeadline(
|
|
2129
|
+
Effect.tryPromise({
|
|
2130
|
+
try: () => page.setRequestInterception(true),
|
|
2131
|
+
catch: (cause) => protocolError("Installing the browser request policy failed", cause),
|
|
2132
|
+
}),
|
|
2133
|
+
fixedPolicy,
|
|
2134
|
+
startedAt,
|
|
2135
|
+
);
|
|
2136
|
+
|
|
2137
|
+
yield* withinDeadline(awaitPendingRequests(state), fixedPolicy, startedAt);
|
|
2138
|
+
const setupFailure = stateFailure(state);
|
|
2139
|
+
|
|
2140
|
+
if (setupFailure !== undefined) return yield* setupFailure;
|
|
2141
|
+
|
|
2142
|
+
const teardown = yield* Effect.uninterruptible(
|
|
2143
|
+
Effect.gen(function* () {
|
|
2144
|
+
const cached = yield* Effect.cached(terminate(Redacted.make(sessionIdValue), closers));
|
|
2145
|
+
|
|
2146
|
+
lifecycle.managedTeardownInstalled = true;
|
|
2147
|
+
yield* Effect.addFinalizer(() =>
|
|
2148
|
+
Effect.uninterruptible(
|
|
2149
|
+
Effect.sync(() => {
|
|
2150
|
+
state.closed.value = true;
|
|
2151
|
+
state.disconnected.value = true;
|
|
2152
|
+
}).pipe(
|
|
2153
|
+
Effect.andThen(cached),
|
|
2154
|
+
Effect.catch(() => Effect.logWarning("Whole-browser cleanup remains unconfirmed")),
|
|
2155
|
+
),
|
|
2156
|
+
),
|
|
2157
|
+
);
|
|
2158
|
+
|
|
2159
|
+
return cached;
|
|
2160
|
+
}),
|
|
2161
|
+
);
|
|
2162
|
+
|
|
2163
|
+
const close: Effect.Effect<void, InteractiveBrowserError> = Effect.uninterruptible(
|
|
2164
|
+
Effect.sync(() => {
|
|
2165
|
+
state.closed.value = true;
|
|
2166
|
+
state.disconnected.value = true;
|
|
2167
|
+
}).pipe(Effect.andThen(teardown)),
|
|
2168
|
+
);
|
|
2169
|
+
|
|
2170
|
+
const runtime = yield* makeHandle(page, fixedPolicy, startedAt, state, close);
|
|
2171
|
+
const currentPagePreflight = decodeActionResult(page, fixedPolicy).pipe(Effect.asVoid);
|
|
2172
|
+
|
|
2173
|
+
const requestFitsSession = (requestedMillis: number): Effect.Effect<void, BrowserFailure> =>
|
|
2174
|
+
remainingMillis(fixedPolicy, startedAt).pipe(
|
|
2175
|
+
Effect.flatMap((remaining) =>
|
|
2176
|
+
remaining > 0 && requestedMillis <= remaining
|
|
2177
|
+
? Effect.void
|
|
2178
|
+
: Effect.fail(
|
|
2179
|
+
policyError("The host browser request exceeds the remaining session time"),
|
|
2180
|
+
),
|
|
2181
|
+
),
|
|
2182
|
+
);
|
|
2183
|
+
|
|
2184
|
+
return {
|
|
2185
|
+
handle: runtime.handle,
|
|
2186
|
+
sessionId: Redacted.make(sessionIdValue),
|
|
2187
|
+
resizeViewport: (viewport) =>
|
|
2188
|
+
decodeViewport(viewport).pipe(
|
|
2189
|
+
Effect.flatMap((decoded) =>
|
|
2190
|
+
runtime.run(
|
|
2191
|
+
Effect.tryPromise({
|
|
2192
|
+
try: () => page.setViewport(decoded),
|
|
2193
|
+
catch: (cause) => {
|
|
2194
|
+
if (state.disconnected.value || isRemoteClosure(cause)) {
|
|
2195
|
+
state.disconnected.value = true;
|
|
2196
|
+
|
|
2197
|
+
return expiredError();
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
return protocolError("Resizing the browser viewport failed", cause);
|
|
2201
|
+
},
|
|
2202
|
+
}),
|
|
2203
|
+
currentPagePreflight,
|
|
2204
|
+
false,
|
|
2205
|
+
),
|
|
2206
|
+
),
|
|
2207
|
+
),
|
|
2208
|
+
getLiveView: (request) =>
|
|
2209
|
+
Schema.decodeEffect(BrowserRunLiveViewRequest)(request).pipe(
|
|
2210
|
+
Effect.mapError(() => policyError("The Live View request is malformed")),
|
|
2211
|
+
Effect.flatMap((decoded) =>
|
|
2212
|
+
runtime.run(
|
|
2213
|
+
cdpCommand(
|
|
2214
|
+
page,
|
|
2215
|
+
state,
|
|
2216
|
+
"Cloudflare.getLiveView",
|
|
2217
|
+
{ mode: decoded.mode, expiresInMs: decoded.expiresInMs },
|
|
2218
|
+
LiveViewObservation,
|
|
2219
|
+
"Cloudflare returned a malformed Live View response",
|
|
2220
|
+
).pipe(
|
|
2221
|
+
Effect.flatMap((observation) =>
|
|
2222
|
+
Schema.decodeEffect(BrowserRunLiveViewResult)({
|
|
2223
|
+
devtoolsFrontendUrl: Redacted.make(observation.devtoolsFrontendUrl),
|
|
2224
|
+
}).pipe(
|
|
2225
|
+
Effect.mapError(() =>
|
|
2226
|
+
protocolError("Cloudflare returned a malformed Live View response"),
|
|
2227
|
+
),
|
|
2228
|
+
),
|
|
2229
|
+
),
|
|
2230
|
+
),
|
|
2231
|
+
currentPagePreflight.pipe(Effect.andThen(requestFitsSession(decoded.expiresInMs))),
|
|
2232
|
+
),
|
|
2233
|
+
),
|
|
2234
|
+
),
|
|
2235
|
+
handoff: (request) =>
|
|
2236
|
+
Schema.decodeEffect(BrowserRunHandoffRequest)(request).pipe(
|
|
2237
|
+
Effect.mapError(() => policyError("The browser handoff request is malformed")),
|
|
2238
|
+
Effect.flatMap((decoded) =>
|
|
2239
|
+
runtime.run(
|
|
2240
|
+
cdpCommand(
|
|
2241
|
+
page,
|
|
2242
|
+
state,
|
|
2243
|
+
"Cloudflare.handoff",
|
|
2244
|
+
{ instructions: decoded.instructions, timeout: decoded.timeout },
|
|
2245
|
+
HandoffObservation,
|
|
2246
|
+
"Cloudflare returned a malformed browser handoff response",
|
|
2247
|
+
).pipe(
|
|
2248
|
+
Effect.flatMap((observation) =>
|
|
2249
|
+
Schema.decodeEffect(BrowserRunHandoffResult)({
|
|
2250
|
+
handoffId: Redacted.make(observation.handoffId),
|
|
2251
|
+
}).pipe(
|
|
2252
|
+
Effect.mapError(() =>
|
|
2253
|
+
protocolError("Cloudflare returned a malformed browser handoff response"),
|
|
2254
|
+
),
|
|
2255
|
+
),
|
|
2256
|
+
),
|
|
2257
|
+
),
|
|
2258
|
+
currentPagePreflight.pipe(Effect.andThen(requestFitsSession(decoded.timeout))),
|
|
2259
|
+
),
|
|
2260
|
+
),
|
|
2261
|
+
),
|
|
2262
|
+
getHandoffState: runtime.run(
|
|
2263
|
+
cdpCommand(
|
|
2264
|
+
page,
|
|
2265
|
+
state,
|
|
2266
|
+
"Cloudflare.getHandoffState",
|
|
2267
|
+
{},
|
|
2268
|
+
HandoffStateObservation,
|
|
2269
|
+
"Cloudflare returned a malformed browser handoff state",
|
|
2270
|
+
).pipe(
|
|
2271
|
+
Effect.flatMap((observation) =>
|
|
2272
|
+
Schema.decodeEffect(BrowserRunHandoffState)({
|
|
2273
|
+
active: observation.active,
|
|
2274
|
+
...(observation.handoffId === undefined
|
|
2275
|
+
? {}
|
|
2276
|
+
: { handoffId: Redacted.make(observation.handoffId) }),
|
|
2277
|
+
...(observation.durationMs === undefined
|
|
2278
|
+
? {}
|
|
2279
|
+
: { durationMs: observation.durationMs }),
|
|
2280
|
+
}).pipe(
|
|
2281
|
+
Effect.mapError(() =>
|
|
2282
|
+
protocolError("Cloudflare returned a malformed browser handoff state"),
|
|
2283
|
+
),
|
|
2284
|
+
),
|
|
2285
|
+
),
|
|
2286
|
+
),
|
|
2287
|
+
currentPagePreflight,
|
|
2288
|
+
),
|
|
2289
|
+
close,
|
|
2290
|
+
};
|
|
2291
|
+
});
|
|
2292
|
+
|
|
2293
|
+
return BrowserRunInteractiveHost.of({
|
|
2294
|
+
open,
|
|
2295
|
+
closeSession,
|
|
2296
|
+
cleanupSemantics: "confirmed-terminal",
|
|
2297
|
+
});
|
|
2298
|
+
};
|
|
2299
|
+
|
|
2300
|
+
/** Cloudflare host controls and private session identity for one scoped Browser Run pass. */
|
|
2301
|
+
export const browserRunInteractiveHostLayer = (): Layer.Layer<
|
|
2302
|
+
BrowserRunInteractiveHost,
|
|
2303
|
+
never,
|
|
2304
|
+
BrowserRunInteractiveBinding
|
|
2305
|
+
> =>
|
|
2306
|
+
Layer.effect(
|
|
2307
|
+
BrowserRunInteractiveHost,
|
|
2308
|
+
Effect.gen(function* () {
|
|
2309
|
+
return makeHostService(yield* BrowserRunInteractiveBinding);
|
|
2310
|
+
}),
|
|
2311
|
+
);
|
|
2312
|
+
|
|
2313
|
+
/** Worker-only generic adapter; Cloudflare identity and controls remain host-only. */
|
|
2314
|
+
export const browserRunInteractiveLayer = (): Layer.Layer<
|
|
2315
|
+
InteractiveBrowser,
|
|
2316
|
+
never,
|
|
2317
|
+
BrowserRunInteractiveBinding
|
|
2318
|
+
> =>
|
|
2319
|
+
Layer.effect(
|
|
2320
|
+
InteractiveBrowser,
|
|
2321
|
+
Effect.gen(function* () {
|
|
2322
|
+
const binding = yield* BrowserRunInteractiveBinding;
|
|
2323
|
+
const host = makeHostService(binding);
|
|
2324
|
+
|
|
2325
|
+
return InteractiveBrowser.of({
|
|
2326
|
+
open: (policy) => host.open(policy).pipe(Effect.map((session) => session.handle)),
|
|
2327
|
+
});
|
|
2328
|
+
}),
|
|
2329
|
+
);
|
|
2330
|
+
|
|
2331
|
+
/** Resolved Worker binding, cleanup credentials, and optional initial viewport. */
|
|
2332
|
+
export interface CloudflareInteractiveBrowserOptions extends BrowserRunLifecycleOptions {
|
|
2333
|
+
readonly browser: BrowserRun;
|
|
2334
|
+
readonly viewport?: BrowserRunViewport;
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
const interactiveBindingLayer = (options: CloudflareInteractiveBrowserOptions) =>
|
|
2338
|
+
BrowserRunInteractiveBinding.layer(options).pipe(
|
|
2339
|
+
Layer.provide(BrowserRunSessionLifecycle.layer(options)),
|
|
2340
|
+
);
|
|
2341
|
+
|
|
2342
|
+
/** Worker-only assembly; importing ordinary capture never loads Puppeteer. */
|
|
2343
|
+
export const CloudflareInteractiveBrowser = {
|
|
2344
|
+
/**
|
|
2345
|
+
* Assemble binding, confirmed-session cleanup, and the generic browser service.
|
|
2346
|
+
* HttpClient and construction errors remain visible. Opening a pass still requires
|
|
2347
|
+
* an explicit policy and Scope; no browser is launched during Layer construction.
|
|
2348
|
+
*/
|
|
2349
|
+
layer: (options: CloudflareInteractiveBrowserOptions) =>
|
|
2350
|
+
browserRunInteractiveLayer().pipe(Layer.provide(interactiveBindingLayer(options))),
|
|
2351
|
+
/** Opt into private host controls instead of exposing them through the generic service. */
|
|
2352
|
+
hostLayer: (options: CloudflareInteractiveBrowserOptions) =>
|
|
2353
|
+
browserRunInteractiveHostLayer().pipe(Layer.provide(interactiveBindingLayer(options))),
|
|
2354
|
+
};
|