@effect-agent/platform-cloudflare 0.1.0-beta.31 → 0.1.0-beta.33
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/index.d.mts +22 -22
- package/dist/index.mjs +2 -2
- package/dist/interactive-browser.d.mts +57 -4
- package/dist/interactive-browser.mjs +289 -59
- package/dist/interactive-browser.mjs.map +1 -1
- package/package.json +6 -6
- package/src/interactive-browser.ts +754 -154
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
import puppeteer, {
|
|
4
4
|
type Browser,
|
|
5
5
|
type BrowserContext,
|
|
6
|
+
type CDPSession,
|
|
6
7
|
type HTTPRequest,
|
|
7
8
|
type Page,
|
|
8
9
|
} from "@cloudflare/puppeteer";
|
|
9
10
|
import {
|
|
10
11
|
BrowserActionResult,
|
|
12
|
+
BrowserScreenshotRequest,
|
|
13
|
+
BrowserScrollRequest,
|
|
11
14
|
BrowserNavigationResult,
|
|
12
15
|
BrowserTextResult,
|
|
13
16
|
InteractiveBrowser,
|
|
@@ -19,11 +22,23 @@ import {
|
|
|
19
22
|
InteractiveBrowserPolicy,
|
|
20
23
|
InteractiveBrowserPolicyDeniedError,
|
|
21
24
|
InteractiveBrowserProtocolError,
|
|
25
|
+
PageScreenshotResult,
|
|
22
26
|
SandboxImplementation,
|
|
23
27
|
type BrowserHandle,
|
|
24
28
|
type InteractiveBrowserError,
|
|
25
29
|
} from "@effect-agent/sandbox";
|
|
26
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
Context,
|
|
32
|
+
Duration,
|
|
33
|
+
Effect,
|
|
34
|
+
Layer,
|
|
35
|
+
Option,
|
|
36
|
+
Redacted,
|
|
37
|
+
Ref,
|
|
38
|
+
Schema,
|
|
39
|
+
Semaphore,
|
|
40
|
+
type Scope,
|
|
41
|
+
} from "effect";
|
|
27
42
|
|
|
28
43
|
export const browserRunInteractiveImplementation = SandboxImplementation.make({
|
|
29
44
|
isolation: "isolated",
|
|
@@ -33,12 +48,123 @@ export const browserRunInteractiveImplementation = SandboxImplementation.make({
|
|
|
33
48
|
const MIN_KEEP_ALIVE_MILLIS = 10_000;
|
|
34
49
|
const MAX_KEEP_ALIVE_MILLIS = 600_000;
|
|
35
50
|
const MAX_TEXT_LENGTH = 8 * 1024 * 1024;
|
|
51
|
+
const MAX_SCREENSHOT_BYTES = 8 * 1024 * 1024;
|
|
52
|
+
const MIN_LIVE_VIEW_EXPIRY_MILLIS = 60_000;
|
|
53
|
+
const MAX_LIVE_VIEW_EXPIRY_MILLIS = 60 * 60_000;
|
|
54
|
+
const MAX_HANDOFF_TIMEOUT_MILLIS = 30 * 60_000;
|
|
55
|
+
const MAX_HOST_TEXT_LENGTH = 8 * 1024;
|
|
56
|
+
const CLEANUP_STEP_TIMEOUT_MILLIS = 10_000;
|
|
57
|
+
const CLOSE_SESSION_TIMEOUT_MILLIS = 10_000;
|
|
58
|
+
const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0));
|
|
59
|
+
const BoundedHostText = Schema.String.check(
|
|
60
|
+
Schema.isMinLength(1),
|
|
61
|
+
Schema.isMaxLength(MAX_HOST_TEXT_LENGTH),
|
|
62
|
+
);
|
|
36
63
|
const BoundedRemoteText = Schema.String.check(Schema.isMaxLength(MAX_TEXT_LENGTH));
|
|
37
64
|
const TextObservation = Schema.Union([
|
|
38
65
|
Schema.Struct({ _tag: Schema.Literal("Text"), text: BoundedRemoteText }),
|
|
39
66
|
Schema.Struct({ _tag: Schema.Literal("MissingElement") }),
|
|
40
67
|
Schema.Struct({ _tag: Schema.Literal("OverLimit"), observed: Schema.Natural }),
|
|
41
68
|
]);
|
|
69
|
+
const PngBytes = Schema.Uint8Array.check(
|
|
70
|
+
Schema.isMaxLength(MAX_SCREENSHOT_BYTES),
|
|
71
|
+
Schema.makeFilter(
|
|
72
|
+
(bytes) =>
|
|
73
|
+
bytes.length >= 8 &&
|
|
74
|
+
bytes[0] === 0x89 &&
|
|
75
|
+
bytes[1] === 0x50 &&
|
|
76
|
+
bytes[2] === 0x4e &&
|
|
77
|
+
bytes[3] === 0x47 &&
|
|
78
|
+
bytes[4] === 0x0d &&
|
|
79
|
+
bytes[5] === 0x0a &&
|
|
80
|
+
bytes[6] === 0x1a &&
|
|
81
|
+
bytes[7] === 0x0a,
|
|
82
|
+
{ title: "PNG bytes" },
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
const BrowserRunSessionId = Schema.String.check(
|
|
86
|
+
Schema.isMinLength(1),
|
|
87
|
+
Schema.isMaxLength(256),
|
|
88
|
+
Schema.makeFilter((value) => /^[A-Za-z0-9_-]+$/.test(value), {
|
|
89
|
+
title: "a Browser Run session identifier",
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
const LiveViewUrl = Schema.String.check(
|
|
93
|
+
Schema.isMaxLength(MAX_HOST_TEXT_LENGTH),
|
|
94
|
+
Schema.makeFilter(
|
|
95
|
+
(value) => {
|
|
96
|
+
try {
|
|
97
|
+
const url = new URL(value);
|
|
98
|
+
return (
|
|
99
|
+
url.protocol === "https:" &&
|
|
100
|
+
url.host === "live.browser.run" &&
|
|
101
|
+
url.username === "" &&
|
|
102
|
+
url.password === "" &&
|
|
103
|
+
url.pathname === "/ui/view" &&
|
|
104
|
+
url.searchParams.get("mode") === "tab" &&
|
|
105
|
+
(url.searchParams.get("wss") ?? "").startsWith("live.browser.run/api/devtools/browser/")
|
|
106
|
+
);
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
{ title: "a Cloudflare Live View HTTPS URL" },
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
const LiveViewObservation = Schema.Struct({ devtoolsFrontendUrl: LiveViewUrl });
|
|
115
|
+
const HandoffObservation = Schema.Struct({ handoffId: BoundedHostText });
|
|
116
|
+
const HandoffDuration = Schema.Natural.check(
|
|
117
|
+
Schema.isLessThanOrEqualTo(MAX_HANDOFF_TIMEOUT_MILLIS),
|
|
118
|
+
);
|
|
119
|
+
const HandoffStateObservation = Schema.Union([
|
|
120
|
+
Schema.Struct({
|
|
121
|
+
active: Schema.Literal(true),
|
|
122
|
+
handoffId: BoundedHostText,
|
|
123
|
+
durationMs: HandoffDuration,
|
|
124
|
+
}),
|
|
125
|
+
Schema.Struct({
|
|
126
|
+
active: Schema.Literal(false),
|
|
127
|
+
handoffId: Schema.optionalKey(BoundedHostText),
|
|
128
|
+
durationMs: Schema.optionalKey(HandoffDuration),
|
|
129
|
+
}),
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
/** Host-only request for a redacted Cloudflare Live View URL. */
|
|
133
|
+
export class BrowserRunLiveViewRequest extends Schema.Class<BrowserRunLiveViewRequest>(
|
|
134
|
+
"BrowserRunLiveViewRequest",
|
|
135
|
+
)({
|
|
136
|
+
mode: Schema.Literal("tab"),
|
|
137
|
+
expiresInMs: PositiveInt.check(
|
|
138
|
+
Schema.isBetween({
|
|
139
|
+
minimum: MIN_LIVE_VIEW_EXPIRY_MILLIS,
|
|
140
|
+
maximum: MAX_LIVE_VIEW_EXPIRY_MILLIS,
|
|
141
|
+
}),
|
|
142
|
+
),
|
|
143
|
+
}) {}
|
|
144
|
+
|
|
145
|
+
export class BrowserRunLiveViewResult extends Schema.Class<BrowserRunLiveViewResult>(
|
|
146
|
+
"BrowserRunLiveViewResult",
|
|
147
|
+
)({ devtoolsFrontendUrl: Schema.Redacted(LiveViewUrl) }) {}
|
|
148
|
+
|
|
149
|
+
/** Start one bounded handoff; controller ownership remains a consumer concern. */
|
|
150
|
+
export class BrowserRunHandoffRequest extends Schema.Class<BrowserRunHandoffRequest>(
|
|
151
|
+
"BrowserRunHandoffRequest",
|
|
152
|
+
)({
|
|
153
|
+
instructions: BoundedHostText.check(Schema.isMaxLength(1_024)),
|
|
154
|
+
timeout: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_HANDOFF_TIMEOUT_MILLIS)),
|
|
155
|
+
}) {}
|
|
156
|
+
|
|
157
|
+
export class BrowserRunHandoffResult extends Schema.Class<BrowserRunHandoffResult>(
|
|
158
|
+
"BrowserRunHandoffResult",
|
|
159
|
+
)({ handoffId: Schema.Redacted(BoundedHostText) }) {}
|
|
160
|
+
|
|
161
|
+
export class BrowserRunHandoffState extends Schema.Class<BrowserRunHandoffState>(
|
|
162
|
+
"BrowserRunHandoffState",
|
|
163
|
+
)({
|
|
164
|
+
active: Schema.Boolean,
|
|
165
|
+
handoffId: Schema.optionalKey(Schema.Redacted(BoundedHostText)),
|
|
166
|
+
durationMs: Schema.optionalKey(HandoffDuration),
|
|
167
|
+
}) {}
|
|
42
168
|
|
|
43
169
|
type BrowserFailure = typeof InteractiveBrowserError.Type;
|
|
44
170
|
type BrowserOperation = InteractiveBrowserActionError["operation"];
|
|
@@ -59,6 +185,17 @@ export interface BrowserRunInteractiveRequest {
|
|
|
59
185
|
|
|
60
186
|
export type BrowserRunInteractiveRequestListener = (request: BrowserRunInteractiveRequest) => void;
|
|
61
187
|
|
|
188
|
+
export type BrowserRunCloudflareCommand =
|
|
189
|
+
| "Cloudflare.getLiveView"
|
|
190
|
+
| "Cloudflare.handoff"
|
|
191
|
+
| "Cloudflare.getHandoffState";
|
|
192
|
+
|
|
193
|
+
/** Narrow CDP boundary for Cloudflare commands absent from the pinned protocol types. */
|
|
194
|
+
export interface BrowserRunInteractiveCdpSession {
|
|
195
|
+
readonly send: (command: BrowserRunCloudflareCommand, parameters: unknown) => Promise<unknown>;
|
|
196
|
+
readonly detach: () => Promise<void>;
|
|
197
|
+
}
|
|
198
|
+
|
|
62
199
|
/** Narrow page boundary used by deterministic tests; SDK values remain in this package. */
|
|
63
200
|
export interface BrowserRunInteractivePage {
|
|
64
201
|
readonly close: () => Promise<void>;
|
|
@@ -71,6 +208,9 @@ export interface BrowserRunInteractivePage {
|
|
|
71
208
|
readonly readText: (selector: string | undefined, maximumBytes: number) => Promise<unknown>;
|
|
72
209
|
readonly fill: (selector: string, value: string) => Promise<void>;
|
|
73
210
|
readonly click: (selector: string) => Promise<void>;
|
|
211
|
+
readonly screenshot: (fullPage: boolean) => Promise<unknown>;
|
|
212
|
+
readonly scroll: (deltaX: number, deltaY: number) => Promise<void>;
|
|
213
|
+
readonly createCdpSession: () => Promise<BrowserRunInteractiveCdpSession>;
|
|
74
214
|
}
|
|
75
215
|
|
|
76
216
|
export interface BrowserRunInteractiveContext {
|
|
@@ -81,6 +221,7 @@ export interface BrowserRunInteractiveContext {
|
|
|
81
221
|
export interface BrowserRunInteractiveBrowser {
|
|
82
222
|
readonly createContext: () => Promise<BrowserRunInteractiveContext>;
|
|
83
223
|
readonly close: () => Promise<void>;
|
|
224
|
+
readonly sessionId: () => unknown;
|
|
84
225
|
readonly isConnected: () => boolean;
|
|
85
226
|
readonly onDisconnected: (listener: () => void) => void;
|
|
86
227
|
readonly offDisconnected: (listener: () => void) => void;
|
|
@@ -91,6 +232,7 @@ export class BrowserRunInteractiveBinding extends Context.Service<
|
|
|
91
232
|
BrowserRunInteractiveBinding,
|
|
92
233
|
{
|
|
93
234
|
readonly launch: (keepAliveMillis: number) => Promise<BrowserRunInteractiveBrowser>;
|
|
235
|
+
readonly connect: (sessionId: string) => Promise<BrowserRunInteractiveBrowser>;
|
|
94
236
|
}
|
|
95
237
|
>()("@effect-agent/platform-cloudflare/BrowserRunInteractiveBinding") {
|
|
96
238
|
static layer(options: {
|
|
@@ -103,16 +245,52 @@ export class BrowserRunInteractiveBinding extends Context.Service<
|
|
|
103
245
|
keep_alive: keepAliveMillis,
|
|
104
246
|
}),
|
|
105
247
|
),
|
|
248
|
+
connect: async (sessionId) =>
|
|
249
|
+
makeProductionBrowser(await puppeteer.connect(options.browser, sessionId)),
|
|
106
250
|
});
|
|
107
251
|
}
|
|
108
252
|
}
|
|
109
253
|
|
|
254
|
+
export interface BrowserRunInteractiveSession {
|
|
255
|
+
readonly handle: BrowserHandle;
|
|
256
|
+
readonly sessionId: Redacted.Redacted<string>;
|
|
257
|
+
readonly getLiveView: (
|
|
258
|
+
request: BrowserRunLiveViewRequest,
|
|
259
|
+
) => Effect.Effect<BrowserRunLiveViewResult, InteractiveBrowserError>;
|
|
260
|
+
readonly handoff: (
|
|
261
|
+
request: BrowserRunHandoffRequest,
|
|
262
|
+
) => Effect.Effect<BrowserRunHandoffResult, InteractiveBrowserError>;
|
|
263
|
+
readonly getHandoffState: Effect.Effect<BrowserRunHandoffState, InteractiveBrowserError>;
|
|
264
|
+
readonly close: Effect.Effect<void, InteractiveBrowserError>;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Cloudflare host authority kept separate from the provider-neutral browser handle. */
|
|
268
|
+
export class BrowserRunInteractiveHost extends Context.Service<
|
|
269
|
+
BrowserRunInteractiveHost,
|
|
270
|
+
{
|
|
271
|
+
readonly open: (
|
|
272
|
+
policy: InteractiveBrowserPolicy,
|
|
273
|
+
) => Effect.Effect<BrowserRunInteractiveSession, InteractiveBrowserError, Scope.Scope>;
|
|
274
|
+
readonly closeSession: (
|
|
275
|
+
sessionId: Redacted.Redacted<string>,
|
|
276
|
+
) => Effect.Effect<void, InteractiveBrowserError>;
|
|
277
|
+
}
|
|
278
|
+
>()("@effect-agent/platform-cloudflare/BrowserRunInteractiveHost") {}
|
|
279
|
+
|
|
110
280
|
const makeProductionRequest = (request: HTTPRequest): BrowserRunInteractiveRequest => ({
|
|
111
281
|
url: () => request.url(),
|
|
112
282
|
abort: () => request.abort("blockedbyclient"),
|
|
113
283
|
continue: () => request.continue(),
|
|
114
284
|
});
|
|
115
285
|
|
|
286
|
+
const makeProductionCdpSession = (session: CDPSession): BrowserRunInteractiveCdpSession => ({
|
|
287
|
+
send: async (command, parameters) => {
|
|
288
|
+
const send = Reflect.get(session, "send");
|
|
289
|
+
return await Reflect.apply(send, session, [command, parameters]);
|
|
290
|
+
},
|
|
291
|
+
detach: () => session.detach(),
|
|
292
|
+
});
|
|
293
|
+
|
|
116
294
|
const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
|
|
117
295
|
const listeners = new Map<BrowserRunInteractiveRequestListener, (request: HTTPRequest) => void>();
|
|
118
296
|
return {
|
|
@@ -164,12 +342,23 @@ const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
|
|
|
164
342
|
await page.$eval(
|
|
165
343
|
selector,
|
|
166
344
|
(element, nextValue) => {
|
|
167
|
-
|
|
345
|
+
// Bypass instance setters so React can detect the change when events fire.
|
|
346
|
+
let prototype = Reflect.getPrototypeOf(element);
|
|
347
|
+
let setValue: ((value: string) => void) | undefined;
|
|
348
|
+
while (prototype !== null) {
|
|
349
|
+
const setter = Reflect.getOwnPropertyDescriptor(prototype, "value")?.set;
|
|
350
|
+
if (typeof setter === "function") {
|
|
351
|
+
setValue = setter;
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
prototype = Reflect.getPrototypeOf(prototype);
|
|
355
|
+
}
|
|
356
|
+
if (setValue === undefined) {
|
|
168
357
|
throw new Error("The selector did not resolve to a fillable field");
|
|
169
358
|
}
|
|
170
359
|
const focus = Reflect.get(element, "focus");
|
|
171
360
|
if (typeof focus === "function") Reflect.apply(focus, element, []);
|
|
172
|
-
Reflect.
|
|
361
|
+
Reflect.apply(setValue, element, [nextValue]);
|
|
173
362
|
const dispatchEvent = Reflect.get(element, "dispatchEvent");
|
|
174
363
|
if (typeof dispatchEvent === "function") {
|
|
175
364
|
Reflect.apply(dispatchEvent, element, [new Event("input", { bubbles: true })]);
|
|
@@ -180,6 +369,19 @@ const makeProductionPage = (page: Page): BrowserRunInteractivePage => {
|
|
|
180
369
|
);
|
|
181
370
|
},
|
|
182
371
|
click: (selector) => page.click(selector),
|
|
372
|
+
// Puppeteer materializes the complete image before returning. The adapter
|
|
373
|
+
// validates the 8 MiB Schema ceiling and pass limit immediately afterward.
|
|
374
|
+
screenshot: (fullPage) => page.screenshot({ type: "png", fullPage }),
|
|
375
|
+
scroll: (deltaX, deltaY) =>
|
|
376
|
+
page.evaluate(
|
|
377
|
+
(x, y) => {
|
|
378
|
+
const scrollBy = Reflect.get(globalThis, "scrollBy");
|
|
379
|
+
Reflect.apply(scrollBy, globalThis, [{ left: x, top: y, behavior: "instant" }]);
|
|
380
|
+
},
|
|
381
|
+
deltaX,
|
|
382
|
+
deltaY,
|
|
383
|
+
),
|
|
384
|
+
createCdpSession: async () => makeProductionCdpSession(await page.createCDPSession()),
|
|
183
385
|
};
|
|
184
386
|
};
|
|
185
387
|
|
|
@@ -191,6 +393,7 @@ const makeProductionContext = (context: BrowserContext): BrowserRunInteractiveCo
|
|
|
191
393
|
const makeProductionBrowser = (browser: Browser): BrowserRunInteractiveBrowser => ({
|
|
192
394
|
createContext: async () => makeProductionContext(await browser.createBrowserContext()),
|
|
193
395
|
close: () => browser.close(),
|
|
396
|
+
sessionId: () => browser.sessionId(),
|
|
194
397
|
isConnected: () => browser.isConnected(),
|
|
195
398
|
onDisconnected: (listener) => {
|
|
196
399
|
browser.on("disconnected", listener);
|
|
@@ -272,13 +475,10 @@ const hostAllowed = (policy: InteractiveBrowserPolicySnapshot, value: string): b
|
|
|
272
475
|
const keepAliveMillis = (policy: InteractiveBrowserPolicySnapshot): number =>
|
|
273
476
|
Math.max(MIN_KEEP_ALIVE_MILLIS, Math.min(MAX_KEEP_ALIVE_MILLIS, policy.maxElapsedMillis));
|
|
274
477
|
|
|
275
|
-
|
|
276
|
-
readonly close: () => Promise<void>;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const closeLateAcquisition = async <A extends CloseableRemote>(
|
|
478
|
+
const closeLateAcquisition = async <A>(
|
|
280
479
|
signal: AbortSignal,
|
|
281
480
|
acquire: () => Promise<A>,
|
|
481
|
+
close: (acquired: A) => Promise<void>,
|
|
282
482
|
): Promise<A> => {
|
|
283
483
|
const acquired = await acquire();
|
|
284
484
|
if (!signal.aborted) return acquired;
|
|
@@ -286,7 +486,7 @@ const closeLateAcquisition = async <A extends CloseableRemote>(
|
|
|
286
486
|
// The SDK does not accept AbortSignal. If an acquisition settles after Effect
|
|
287
487
|
// has interrupted it, ownership never reaches Scope, so close it here instead.
|
|
288
488
|
try {
|
|
289
|
-
await
|
|
489
|
+
await close(acquired);
|
|
290
490
|
} catch {
|
|
291
491
|
// No caller remains to observe a late cleanup failure, and provider details
|
|
292
492
|
// must not escape through an unhandled rejection.
|
|
@@ -298,7 +498,13 @@ const closeWithWarning = (close: () => Promise<void>, warning: string): Effect.E
|
|
|
298
498
|
Effect.tryPromise({
|
|
299
499
|
try: close,
|
|
300
500
|
catch: () => protocolError(warning),
|
|
301
|
-
}).pipe(
|
|
501
|
+
}).pipe(
|
|
502
|
+
Effect.timeoutOrElse({
|
|
503
|
+
duration: Duration.millis(CLEANUP_STEP_TIMEOUT_MILLIS),
|
|
504
|
+
orElse: () => Effect.fail(protocolError(warning)),
|
|
505
|
+
}),
|
|
506
|
+
Effect.catchCause(() => Effect.logWarning(warning)),
|
|
507
|
+
);
|
|
302
508
|
|
|
303
509
|
const deadlineError = Effect.fn("BrowserRunInteractive.deadlineError")(function* (
|
|
304
510
|
policy: InteractiveBrowserPolicySnapshot,
|
|
@@ -334,6 +540,7 @@ const withinDeadline = Effect.fn("BrowserRunInteractive.withinDeadline")(functio
|
|
|
334
540
|
});
|
|
335
541
|
|
|
336
542
|
interface HandleState {
|
|
543
|
+
readonly closed: { value: boolean };
|
|
337
544
|
readonly disconnected: { value: boolean };
|
|
338
545
|
readonly uncertain: { value: boolean };
|
|
339
546
|
readonly violation: { value: BrowserFailure | undefined };
|
|
@@ -342,10 +549,30 @@ interface HandleState {
|
|
|
342
549
|
|
|
343
550
|
const stateFailure = (state: HandleState): BrowserFailure | undefined => {
|
|
344
551
|
if (state.violation.value !== undefined) return state.violation.value;
|
|
345
|
-
if (state.disconnected.value || state.uncertain.value)
|
|
552
|
+
if (state.closed.value || state.disconnected.value || state.uncertain.value) {
|
|
553
|
+
return expiredError();
|
|
554
|
+
}
|
|
346
555
|
return undefined;
|
|
347
556
|
};
|
|
348
557
|
|
|
558
|
+
interface CloseFailure {
|
|
559
|
+
readonly error: InteractiveBrowserActionError;
|
|
560
|
+
readonly warning: string;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
interface CloseEntry {
|
|
564
|
+
readonly close: Effect.Effect<void, InteractiveBrowserActionError>;
|
|
565
|
+
readonly warning: string;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
interface HandleRuntime {
|
|
569
|
+
readonly handle: BrowserHandle;
|
|
570
|
+
readonly run: <A>(
|
|
571
|
+
effect: Effect.Effect<A, BrowserFailure>,
|
|
572
|
+
preflight?: Effect.Effect<void, BrowserFailure>,
|
|
573
|
+
) => Effect.Effect<A, BrowserFailure>;
|
|
574
|
+
}
|
|
575
|
+
|
|
349
576
|
const awaitPendingRequests = (state: HandleState): Effect.Effect<void> =>
|
|
350
577
|
Effect.suspend(() => {
|
|
351
578
|
const pending = [...state.pendingRequests];
|
|
@@ -433,7 +660,8 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
|
|
|
433
660
|
policy: InteractiveBrowserPolicySnapshot,
|
|
434
661
|
startedAt: number,
|
|
435
662
|
state: HandleState,
|
|
436
|
-
|
|
663
|
+
close: Effect.Effect<void, InteractiveBrowserError>,
|
|
664
|
+
): Effect.fn.Return<HandleRuntime> {
|
|
437
665
|
const permits = yield* Semaphore.make(1);
|
|
438
666
|
const actions = yield* Ref.make(0);
|
|
439
667
|
|
|
@@ -520,7 +748,7 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
|
|
|
520
748
|
),
|
|
521
749
|
);
|
|
522
750
|
|
|
523
|
-
|
|
751
|
+
const handle: BrowserHandle = {
|
|
524
752
|
navigate: (request) =>
|
|
525
753
|
run(
|
|
526
754
|
Effect.gen(function* () {
|
|
@@ -587,162 +815,534 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (
|
|
|
587
815
|
Effect.andThen(decodeActionResult(page, policy)),
|
|
588
816
|
),
|
|
589
817
|
),
|
|
818
|
+
screenshot: (request) =>
|
|
819
|
+
Schema.decodeUnknownEffect(BrowserScreenshotRequest)(request).pipe(
|
|
820
|
+
Effect.mapError(() => policyError("The browser screenshot request is malformed")),
|
|
821
|
+
Effect.flatMap((decoded) =>
|
|
822
|
+
run(
|
|
823
|
+
Effect.gen(function* () {
|
|
824
|
+
const raw = yield* remote("screenshot", () => page.screenshot(decoded.fullPage));
|
|
825
|
+
const bytes = yield* Schema.decodeUnknownEffect(PngBytes)(raw).pipe(
|
|
826
|
+
Effect.mapError(() =>
|
|
827
|
+
protocolError("The browser returned a malformed PNG screenshot"),
|
|
828
|
+
),
|
|
829
|
+
);
|
|
830
|
+
if (bytes.length > policy.maxReturnedBytes) {
|
|
831
|
+
return yield* InteractiveBrowserLimitError.make({
|
|
832
|
+
implementation: browserRunInteractiveImplementation,
|
|
833
|
+
limit: "returned-bytes",
|
|
834
|
+
maximum: policy.maxReturnedBytes,
|
|
835
|
+
observed: bytes.length,
|
|
836
|
+
message: "The browser screenshot byte limit was reached",
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
return yield* Schema.decodeUnknownEffect(PageScreenshotResult)({
|
|
840
|
+
implementation: browserRunInteractiveImplementation,
|
|
841
|
+
mediaType: "image/png",
|
|
842
|
+
bytes: new Uint8Array(bytes),
|
|
843
|
+
}).pipe(
|
|
844
|
+
Effect.mapError(() =>
|
|
845
|
+
protocolError("The browser returned a malformed PNG screenshot"),
|
|
846
|
+
),
|
|
847
|
+
);
|
|
848
|
+
}),
|
|
849
|
+
decodeActionResult(page, policy).pipe(Effect.asVoid),
|
|
850
|
+
),
|
|
851
|
+
),
|
|
852
|
+
),
|
|
853
|
+
scroll: (request) =>
|
|
854
|
+
Schema.decodeUnknownEffect(BrowserScrollRequest)(request).pipe(
|
|
855
|
+
Effect.mapError(() => policyError("The browser scroll request is malformed")),
|
|
856
|
+
Effect.flatMap((decoded) =>
|
|
857
|
+
run(
|
|
858
|
+
remote("scroll", () => page.scroll(decoded.deltaX, decoded.deltaY)).pipe(
|
|
859
|
+
Effect.andThen(decodeActionResult(page, policy)),
|
|
860
|
+
),
|
|
861
|
+
decodeActionResult(page, policy).pipe(Effect.asVoid),
|
|
862
|
+
),
|
|
863
|
+
),
|
|
864
|
+
),
|
|
865
|
+
close,
|
|
590
866
|
};
|
|
867
|
+
return { handle, run };
|
|
591
868
|
});
|
|
592
869
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
870
|
+
const remainingMillis = Effect.fn("BrowserRunInteractive.remainingMillis")(function* (
|
|
871
|
+
policy: InteractiveBrowserPolicySnapshot,
|
|
872
|
+
startedAt: number,
|
|
873
|
+
) {
|
|
874
|
+
const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
|
|
875
|
+
return Math.max(0, policy.maxElapsedMillis - Math.max(0, now - startedAt));
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
const closeEntry = (close: () => Promise<void>, warning: string): CloseEntry => ({
|
|
879
|
+
close: Effect.tryPromise({
|
|
880
|
+
try: close,
|
|
881
|
+
catch: (cause) => actionError("close", cause),
|
|
882
|
+
}).pipe(
|
|
883
|
+
Effect.timeoutOrElse({
|
|
884
|
+
duration: Duration.millis(CLEANUP_STEP_TIMEOUT_MILLIS),
|
|
885
|
+
orElse: () => Effect.fail(actionError("close")),
|
|
886
|
+
}),
|
|
887
|
+
),
|
|
888
|
+
warning,
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
const syncCloseEntry = (close: () => void, warning: string): CloseEntry => ({
|
|
892
|
+
close: Effect.try({
|
|
893
|
+
try: close,
|
|
894
|
+
catch: (cause) => actionError("close", cause),
|
|
895
|
+
}),
|
|
896
|
+
warning,
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
const runTeardown = (
|
|
900
|
+
entries: ReadonlyArray<CloseEntry>,
|
|
901
|
+
): Effect.Effect<ReadonlyArray<CloseFailure>> =>
|
|
902
|
+
Effect.forEach([...entries].reverse(), (entry) =>
|
|
903
|
+
entry.close.pipe(
|
|
904
|
+
Effect.match({
|
|
905
|
+
onFailure: (error): CloseFailure | undefined => ({ error, warning: entry.warning }),
|
|
906
|
+
onSuccess: (): CloseFailure | undefined => undefined,
|
|
907
|
+
}),
|
|
908
|
+
),
|
|
909
|
+
).pipe(Effect.map((failures) => failures.filter((failure) => failure !== undefined)));
|
|
910
|
+
|
|
911
|
+
const cdpCommand = <A>(
|
|
912
|
+
page: BrowserRunInteractivePage,
|
|
913
|
+
state: HandleState,
|
|
914
|
+
command: BrowserRunCloudflareCommand,
|
|
915
|
+
parameters: unknown,
|
|
916
|
+
output: Schema.Codec<A>,
|
|
917
|
+
malformedMessage: string,
|
|
918
|
+
): Effect.Effect<A, InteractiveBrowserError> =>
|
|
919
|
+
Effect.scoped(
|
|
601
920
|
Effect.gen(function* () {
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
921
|
+
const cdp = yield* Effect.acquireRelease(
|
|
922
|
+
Effect.tryPromise({
|
|
923
|
+
try: (signal) =>
|
|
924
|
+
closeLateAcquisition(signal, page.createCdpSession, (acquired) => acquired.detach()),
|
|
925
|
+
catch: (cause) =>
|
|
926
|
+
state.disconnected.value || isRemoteClosure(cause)
|
|
927
|
+
? expiredError()
|
|
928
|
+
: protocolError("Creating the Cloudflare browser control session failed", cause),
|
|
929
|
+
}),
|
|
930
|
+
(acquired) =>
|
|
931
|
+
closeWithWarning(
|
|
932
|
+
acquired.detach,
|
|
933
|
+
"Detaching the Cloudflare browser control session failed",
|
|
934
|
+
),
|
|
935
|
+
{ interruptible: true },
|
|
936
|
+
);
|
|
937
|
+
const raw = yield* Effect.tryPromise({
|
|
938
|
+
try: () => cdp.send(command, parameters),
|
|
939
|
+
catch: (cause) =>
|
|
940
|
+
state.disconnected.value || isRemoteClosure(cause)
|
|
941
|
+
? expiredError()
|
|
942
|
+
: protocolError("The Cloudflare browser control command failed", cause),
|
|
943
|
+
});
|
|
944
|
+
return yield* Schema.decodeUnknownEffect(output)(raw).pipe(
|
|
945
|
+
Effect.mapError(() => protocolError(malformedMessage)),
|
|
946
|
+
);
|
|
947
|
+
}),
|
|
948
|
+
);
|
|
949
|
+
|
|
950
|
+
const makeHostService = (
|
|
951
|
+
binding: BrowserRunInteractiveBinding["Service"],
|
|
952
|
+
): BrowserRunInteractiveHost["Service"] => {
|
|
953
|
+
const open = Effect.fn("BrowserRunInteractiveHost.open")(function* (
|
|
954
|
+
policy: InteractiveBrowserPolicy,
|
|
955
|
+
): Effect.fn.Return<BrowserRunInteractiveSession, InteractiveBrowserError, Scope.Scope> {
|
|
956
|
+
const fixedPolicy = yield* snapshotPolicy(policy);
|
|
957
|
+
const startedAt = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
|
|
958
|
+
const state: HandleState = {
|
|
959
|
+
closed: { value: false },
|
|
960
|
+
disconnected: { value: false },
|
|
961
|
+
uncertain: { value: false },
|
|
962
|
+
violation: { value: undefined },
|
|
963
|
+
pendingRequests: new Set(),
|
|
964
|
+
};
|
|
965
|
+
const lifecycle = {
|
|
966
|
+
managedTeardownInstalled: false,
|
|
967
|
+
explicitCloseInvoked: false,
|
|
968
|
+
};
|
|
969
|
+
const closers: Array<CloseEntry> = [];
|
|
970
|
+
const releaseBeforeManaged = (entry: CloseEntry): Effect.Effect<void> =>
|
|
971
|
+
Effect.suspend(() =>
|
|
972
|
+
lifecycle.managedTeardownInstalled
|
|
973
|
+
? Effect.void
|
|
974
|
+
: entry.close.pipe(Effect.catchCause(() => Effect.logWarning(entry.warning))),
|
|
975
|
+
);
|
|
976
|
+
|
|
977
|
+
const browser = yield* Effect.acquireRelease(
|
|
978
|
+
withinDeadline(
|
|
979
|
+
Effect.tryPromise({
|
|
980
|
+
try: (signal) =>
|
|
981
|
+
closeLateAcquisition(
|
|
982
|
+
signal,
|
|
983
|
+
() => binding.launch(keepAliveMillis(fixedPolicy)),
|
|
984
|
+
(acquired) => acquired.close(),
|
|
985
|
+
),
|
|
986
|
+
catch: (cause) =>
|
|
987
|
+
isCapacityRefusal(cause)
|
|
988
|
+
? InteractiveBrowserCapacityError.make({
|
|
989
|
+
implementation: browserRunInteractiveImplementation,
|
|
990
|
+
message: "Browser Run has no capacity for a new browser session",
|
|
991
|
+
})
|
|
992
|
+
: protocolError("Launching the Browser Run session failed", cause),
|
|
993
|
+
}),
|
|
994
|
+
fixedPolicy,
|
|
995
|
+
startedAt,
|
|
996
|
+
),
|
|
997
|
+
(acquired) => {
|
|
998
|
+
state.disconnected.value = true;
|
|
999
|
+
return releaseBeforeManaged(
|
|
1000
|
+
closeEntry(acquired.close, "Closing the interactive browser failed"),
|
|
1001
|
+
);
|
|
1002
|
+
},
|
|
1003
|
+
{ interruptible: true },
|
|
1004
|
+
);
|
|
1005
|
+
closers.push(closeEntry(browser.close, "Closing the interactive browser failed"));
|
|
1006
|
+
|
|
1007
|
+
const disconnected = () => {
|
|
1008
|
+
state.disconnected.value = true;
|
|
1009
|
+
};
|
|
1010
|
+
yield* Effect.acquireRelease(
|
|
1011
|
+
Effect.try({
|
|
1012
|
+
try: () => browser.onDisconnected(disconnected),
|
|
1013
|
+
catch: (cause) => protocolError("Installing the browser disconnect listener failed", cause),
|
|
1014
|
+
}),
|
|
1015
|
+
() =>
|
|
1016
|
+
releaseBeforeManaged(
|
|
1017
|
+
syncCloseEntry(
|
|
1018
|
+
() => browser.offDisconnected(disconnected),
|
|
1019
|
+
"Removing the browser disconnect listener failed",
|
|
1020
|
+
),
|
|
1021
|
+
),
|
|
1022
|
+
);
|
|
1023
|
+
closers.push(
|
|
1024
|
+
syncCloseEntry(
|
|
1025
|
+
() => browser.offDisconnected(disconnected),
|
|
1026
|
+
"Removing the browser disconnect listener failed",
|
|
1027
|
+
),
|
|
1028
|
+
);
|
|
1029
|
+
const connected = yield* Effect.try({
|
|
1030
|
+
try: browser.isConnected,
|
|
1031
|
+
catch: (cause) => protocolError("Reading the Browser Run connection state failed", cause),
|
|
1032
|
+
});
|
|
1033
|
+
if (!connected) {
|
|
1034
|
+
state.disconnected.value = true;
|
|
1035
|
+
return yield* expiredError();
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const sessionIdValue = yield* Effect.try({
|
|
1039
|
+
try: browser.sessionId,
|
|
1040
|
+
catch: (cause) => protocolError("Reading the Browser Run session identity failed", cause),
|
|
1041
|
+
}).pipe(
|
|
1042
|
+
Effect.flatMap((value) =>
|
|
1043
|
+
Schema.decodeUnknownEffect(BrowserRunSessionId)(value).pipe(
|
|
1044
|
+
Effect.mapError(() => protocolError("The Browser Run session identity was malformed")),
|
|
1045
|
+
),
|
|
1046
|
+
),
|
|
1047
|
+
);
|
|
1048
|
+
|
|
1049
|
+
const context = yield* Effect.acquireRelease(
|
|
1050
|
+
withinDeadline(
|
|
1051
|
+
Effect.tryPromise({
|
|
1052
|
+
try: (signal) =>
|
|
1053
|
+
closeLateAcquisition(signal, browser.createContext, (acquired) => acquired.close()),
|
|
1054
|
+
catch: (cause) =>
|
|
1055
|
+
state.disconnected.value || isRemoteClosure(cause)
|
|
1056
|
+
? expiredError()
|
|
1057
|
+
: protocolError("Creating the browser context failed", cause),
|
|
1058
|
+
}),
|
|
1059
|
+
fixedPolicy,
|
|
1060
|
+
startedAt,
|
|
1061
|
+
),
|
|
1062
|
+
(acquired) =>
|
|
1063
|
+
releaseBeforeManaged(
|
|
1064
|
+
closeEntry(acquired.close, "Closing the interactive browser context failed"),
|
|
1065
|
+
),
|
|
1066
|
+
{ interruptible: true },
|
|
1067
|
+
);
|
|
1068
|
+
closers.push(closeEntry(context.close, "Closing the interactive browser context failed"));
|
|
1069
|
+
const page = yield* Effect.acquireRelease(
|
|
1070
|
+
withinDeadline(
|
|
1071
|
+
Effect.tryPromise({
|
|
1072
|
+
try: (signal) =>
|
|
1073
|
+
closeLateAcquisition(signal, context.newPage, (acquired) => acquired.close()),
|
|
1074
|
+
catch: (cause) =>
|
|
1075
|
+
state.disconnected.value || isRemoteClosure(cause)
|
|
1076
|
+
? expiredError()
|
|
1077
|
+
: protocolError("Creating the browser page failed", cause),
|
|
1078
|
+
}),
|
|
1079
|
+
fixedPolicy,
|
|
1080
|
+
startedAt,
|
|
1081
|
+
),
|
|
1082
|
+
(acquired) =>
|
|
1083
|
+
releaseBeforeManaged(
|
|
1084
|
+
closeEntry(acquired.close, "Closing the interactive browser page failed"),
|
|
1085
|
+
),
|
|
1086
|
+
{ interruptible: true },
|
|
1087
|
+
);
|
|
1088
|
+
closers.push(closeEntry(page.close, "Closing the interactive browser page failed"));
|
|
1089
|
+
|
|
1090
|
+
yield* withinDeadline(
|
|
1091
|
+
Effect.tryPromise({
|
|
1092
|
+
try: () => page.setBypassServiceWorker(true),
|
|
1093
|
+
catch: (cause) => protocolError("Bypassing browser service workers failed", cause),
|
|
1094
|
+
}),
|
|
1095
|
+
fixedPolicy,
|
|
1096
|
+
startedAt,
|
|
1097
|
+
);
|
|
1098
|
+
|
|
1099
|
+
const requestListener = makeRequestListener(fixedPolicy, state);
|
|
1100
|
+
yield* Effect.acquireRelease(
|
|
1101
|
+
Effect.try({
|
|
1102
|
+
try: () => page.onRequest(requestListener),
|
|
1103
|
+
catch: (cause) => protocolError("Installing the browser request listener failed", cause),
|
|
1104
|
+
}),
|
|
1105
|
+
() =>
|
|
1106
|
+
releaseBeforeManaged(
|
|
1107
|
+
syncCloseEntry(
|
|
1108
|
+
() => page.offRequest(requestListener),
|
|
1109
|
+
"Removing the browser request policy failed",
|
|
1110
|
+
),
|
|
1111
|
+
),
|
|
1112
|
+
);
|
|
1113
|
+
closers.push(
|
|
1114
|
+
syncCloseEntry(
|
|
1115
|
+
() => page.offRequest(requestListener),
|
|
1116
|
+
"Removing the browser request policy failed",
|
|
1117
|
+
),
|
|
1118
|
+
);
|
|
1119
|
+
|
|
1120
|
+
yield* withinDeadline(
|
|
1121
|
+
Effect.tryPromise({
|
|
1122
|
+
try: () => page.setRequestInterception(true),
|
|
1123
|
+
catch: (cause) => protocolError("Installing the browser request policy failed", cause),
|
|
1124
|
+
}),
|
|
1125
|
+
fixedPolicy,
|
|
1126
|
+
startedAt,
|
|
1127
|
+
);
|
|
1128
|
+
|
|
1129
|
+
yield* withinDeadline(awaitPendingRequests(state), fixedPolicy, startedAt);
|
|
1130
|
+
const setupFailure = stateFailure(state);
|
|
1131
|
+
if (setupFailure !== undefined) return yield* setupFailure;
|
|
1132
|
+
|
|
1133
|
+
const teardown = yield* Effect.uninterruptible(
|
|
1134
|
+
Effect.gen(function* () {
|
|
1135
|
+
const cached = yield* Effect.cached(runTeardown(closers));
|
|
1136
|
+
lifecycle.managedTeardownInstalled = true;
|
|
1137
|
+
yield* Effect.addFinalizer(() =>
|
|
1138
|
+
Effect.uninterruptible(
|
|
1139
|
+
Effect.sync(() => {
|
|
1140
|
+
state.closed.value = true;
|
|
1141
|
+
state.disconnected.value = true;
|
|
1142
|
+
}).pipe(
|
|
1143
|
+
Effect.andThen(cached),
|
|
1144
|
+
Effect.flatMap((failures) =>
|
|
1145
|
+
lifecycle.explicitCloseInvoked
|
|
1146
|
+
? Effect.void
|
|
1147
|
+
: Effect.forEach(failures, (failure) => Effect.logWarning(failure.warning)).pipe(
|
|
1148
|
+
Effect.asVoid,
|
|
620
1149
|
),
|
|
621
|
-
catch: (cause) =>
|
|
622
|
-
isCapacityRefusal(cause)
|
|
623
|
-
? InteractiveBrowserCapacityError.make({
|
|
624
|
-
implementation: browserRunInteractiveImplementation,
|
|
625
|
-
message: "Browser Run has no capacity for a new browser session",
|
|
626
|
-
})
|
|
627
|
-
: protocolError("Launching the Browser Run session failed", cause),
|
|
628
|
-
}),
|
|
629
|
-
fixedPolicy,
|
|
630
|
-
startedAt,
|
|
631
1150
|
),
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
),
|
|
639
|
-
),
|
|
640
|
-
{ interruptible: true },
|
|
641
|
-
);
|
|
1151
|
+
),
|
|
1152
|
+
),
|
|
1153
|
+
);
|
|
1154
|
+
return cached;
|
|
1155
|
+
}),
|
|
1156
|
+
);
|
|
642
1157
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
}).pipe(
|
|
656
|
-
Effect.catchCause(() =>
|
|
657
|
-
Effect.logWarning("Removing the browser disconnect listener failed"),
|
|
658
|
-
),
|
|
659
|
-
),
|
|
660
|
-
);
|
|
661
|
-
const connected = yield* Effect.try({
|
|
662
|
-
try: browser.isConnected,
|
|
663
|
-
catch: (cause) =>
|
|
664
|
-
protocolError("Reading the Browser Run connection state failed", cause),
|
|
665
|
-
});
|
|
666
|
-
if (!connected) {
|
|
667
|
-
state.disconnected.value = true;
|
|
668
|
-
return yield* expiredError();
|
|
669
|
-
}
|
|
1158
|
+
const close: Effect.Effect<void, InteractiveBrowserError> = Effect.uninterruptible(
|
|
1159
|
+
Effect.sync(() => {
|
|
1160
|
+
lifecycle.explicitCloseInvoked = true;
|
|
1161
|
+
state.closed.value = true;
|
|
1162
|
+
state.disconnected.value = true;
|
|
1163
|
+
}).pipe(
|
|
1164
|
+
Effect.andThen(teardown),
|
|
1165
|
+
Effect.flatMap((failures) =>
|
|
1166
|
+
failures[0] === undefined ? Effect.void : Effect.fail(failures[0].error),
|
|
1167
|
+
),
|
|
1168
|
+
),
|
|
1169
|
+
);
|
|
670
1170
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
fixedPolicy,
|
|
681
|
-
startedAt,
|
|
1171
|
+
const runtime = yield* makeHandle(page, fixedPolicy, startedAt, state, close);
|
|
1172
|
+
const currentPagePreflight = decodeActionResult(page, fixedPolicy).pipe(Effect.asVoid);
|
|
1173
|
+
const requestFitsSession = (requestedMillis: number): Effect.Effect<void, BrowserFailure> =>
|
|
1174
|
+
remainingMillis(fixedPolicy, startedAt).pipe(
|
|
1175
|
+
Effect.flatMap((remaining) =>
|
|
1176
|
+
remaining > 0 && requestedMillis <= remaining
|
|
1177
|
+
? Effect.void
|
|
1178
|
+
: Effect.fail(
|
|
1179
|
+
policyError("The host browser request exceeds the remaining session time"),
|
|
682
1180
|
),
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
1181
|
+
),
|
|
1182
|
+
);
|
|
1183
|
+
|
|
1184
|
+
return {
|
|
1185
|
+
handle: runtime.handle,
|
|
1186
|
+
sessionId: Redacted.make(sessionIdValue),
|
|
1187
|
+
getLiveView: (request) =>
|
|
1188
|
+
Schema.decodeUnknownEffect(BrowserRunLiveViewRequest)(request).pipe(
|
|
1189
|
+
Effect.mapError(() => policyError("The Live View request is malformed")),
|
|
1190
|
+
Effect.flatMap((decoded) =>
|
|
1191
|
+
runtime.run(
|
|
1192
|
+
cdpCommand(
|
|
1193
|
+
page,
|
|
1194
|
+
state,
|
|
1195
|
+
"Cloudflare.getLiveView",
|
|
1196
|
+
{ mode: decoded.mode, expiresInMs: decoded.expiresInMs },
|
|
1197
|
+
LiveViewObservation,
|
|
1198
|
+
"Cloudflare returned a malformed Live View response",
|
|
1199
|
+
).pipe(
|
|
1200
|
+
Effect.flatMap((observation) =>
|
|
1201
|
+
Schema.decodeUnknownEffect(BrowserRunLiveViewResult)({
|
|
1202
|
+
devtoolsFrontendUrl: Redacted.make(observation.devtoolsFrontendUrl),
|
|
1203
|
+
}).pipe(
|
|
1204
|
+
Effect.mapError(() =>
|
|
1205
|
+
protocolError("Cloudflare returned a malformed Live View response"),
|
|
1206
|
+
),
|
|
1207
|
+
),
|
|
1208
|
+
),
|
|
698
1209
|
),
|
|
699
|
-
(
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
}).pipe(
|
|
724
|
-
Effect.catchCause(() =>
|
|
725
|
-
Effect.logWarning("Removing the browser request policy failed"),
|
|
1210
|
+
currentPagePreflight.pipe(Effect.andThen(requestFitsSession(decoded.expiresInMs))),
|
|
1211
|
+
),
|
|
1212
|
+
),
|
|
1213
|
+
),
|
|
1214
|
+
handoff: (request) =>
|
|
1215
|
+
Schema.decodeUnknownEffect(BrowserRunHandoffRequest)(request).pipe(
|
|
1216
|
+
Effect.mapError(() => policyError("The browser handoff request is malformed")),
|
|
1217
|
+
Effect.flatMap((decoded) =>
|
|
1218
|
+
runtime.run(
|
|
1219
|
+
cdpCommand(
|
|
1220
|
+
page,
|
|
1221
|
+
state,
|
|
1222
|
+
"Cloudflare.handoff",
|
|
1223
|
+
{ instructions: decoded.instructions, timeout: decoded.timeout },
|
|
1224
|
+
HandoffObservation,
|
|
1225
|
+
"Cloudflare returned a malformed browser handoff response",
|
|
1226
|
+
).pipe(
|
|
1227
|
+
Effect.flatMap((observation) =>
|
|
1228
|
+
Schema.decodeUnknownEffect(BrowserRunHandoffResult)({
|
|
1229
|
+
handoffId: Redacted.make(observation.handoffId),
|
|
1230
|
+
}).pipe(
|
|
1231
|
+
Effect.mapError(() =>
|
|
1232
|
+
protocolError("Cloudflare returned a malformed browser handoff response"),
|
|
1233
|
+
),
|
|
726
1234
|
),
|
|
727
1235
|
),
|
|
728
|
-
|
|
1236
|
+
),
|
|
1237
|
+
currentPagePreflight.pipe(Effect.andThen(requestFitsSession(decoded.timeout))),
|
|
1238
|
+
),
|
|
1239
|
+
),
|
|
1240
|
+
),
|
|
1241
|
+
getHandoffState: runtime.run(
|
|
1242
|
+
cdpCommand(
|
|
1243
|
+
page,
|
|
1244
|
+
state,
|
|
1245
|
+
"Cloudflare.getHandoffState",
|
|
1246
|
+
{},
|
|
1247
|
+
HandoffStateObservation,
|
|
1248
|
+
"Cloudflare returned a malformed browser handoff state",
|
|
1249
|
+
).pipe(
|
|
1250
|
+
Effect.flatMap((observation) =>
|
|
1251
|
+
Schema.decodeUnknownEffect(BrowserRunHandoffState)({
|
|
1252
|
+
active: observation.active,
|
|
1253
|
+
...(observation.handoffId === undefined
|
|
1254
|
+
? {}
|
|
1255
|
+
: { handoffId: Redacted.make(observation.handoffId) }),
|
|
1256
|
+
...(observation.durationMs === undefined
|
|
1257
|
+
? {}
|
|
1258
|
+
: { durationMs: observation.durationMs }),
|
|
1259
|
+
}).pipe(
|
|
1260
|
+
Effect.mapError(() =>
|
|
1261
|
+
protocolError("Cloudflare returned a malformed browser handoff state"),
|
|
1262
|
+
),
|
|
1263
|
+
),
|
|
1264
|
+
),
|
|
1265
|
+
),
|
|
1266
|
+
currentPagePreflight,
|
|
1267
|
+
),
|
|
1268
|
+
close,
|
|
1269
|
+
};
|
|
1270
|
+
});
|
|
729
1271
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
1272
|
+
const closeSession = Effect.fn("BrowserRunInteractiveHost.closeSession")(function* (
|
|
1273
|
+
sessionId: Redacted.Redacted<string>,
|
|
1274
|
+
) {
|
|
1275
|
+
const decoded = yield* Schema.decodeUnknownEffect(Schema.Redacted(BrowserRunSessionId))(
|
|
1276
|
+
sessionId,
|
|
1277
|
+
).pipe(
|
|
1278
|
+
Effect.mapError(() => policyError("The Browser Run cleanup session identity is malformed")),
|
|
1279
|
+
);
|
|
1280
|
+
return yield* Effect.scoped(
|
|
1281
|
+
Effect.gen(function* () {
|
|
1282
|
+
const closeAttempted = { value: false };
|
|
1283
|
+
const browser = yield* Effect.acquireRelease(
|
|
1284
|
+
Effect.tryPromise({
|
|
1285
|
+
try: (signal) =>
|
|
1286
|
+
closeLateAcquisition(
|
|
1287
|
+
signal,
|
|
1288
|
+
() => binding.connect(Redacted.value(decoded)),
|
|
1289
|
+
(acquired) => acquired.close(),
|
|
1290
|
+
),
|
|
1291
|
+
catch: (cause) => actionError("close", cause),
|
|
1292
|
+
}),
|
|
1293
|
+
(acquired) =>
|
|
1294
|
+
closeAttempted.value
|
|
1295
|
+
? Effect.void
|
|
1296
|
+
: closeWithWarning(acquired.close, "Closing the leaked Browser Run session failed"),
|
|
1297
|
+
{ interruptible: true },
|
|
1298
|
+
);
|
|
1299
|
+
return yield* Effect.tryPromise({
|
|
1300
|
+
try: () => {
|
|
1301
|
+
// Mark and start are synchronous so interruption cannot suppress the
|
|
1302
|
+
// Scope fallback before the one remote close attempt begins.
|
|
1303
|
+
closeAttempted.value = true;
|
|
1304
|
+
return browser.close();
|
|
1305
|
+
},
|
|
1306
|
+
catch: (cause) => actionError("close", cause),
|
|
1307
|
+
});
|
|
1308
|
+
}),
|
|
1309
|
+
).pipe(
|
|
1310
|
+
Effect.timeoutOrElse({
|
|
1311
|
+
duration: Duration.millis(CLOSE_SESSION_TIMEOUT_MILLIS),
|
|
1312
|
+
orElse: () => Effect.fail(actionError("close")),
|
|
1313
|
+
}),
|
|
1314
|
+
);
|
|
1315
|
+
});
|
|
739
1316
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
if (setupFailure !== undefined) return yield* setupFailure;
|
|
1317
|
+
return BrowserRunInteractiveHost.of({ open, closeSession });
|
|
1318
|
+
};
|
|
743
1319
|
|
|
744
|
-
|
|
745
|
-
|
|
1320
|
+
/** Cloudflare host controls and private session identity for one scoped Browser Run pass. */
|
|
1321
|
+
export const browserRunInteractiveHostLayer = (): Layer.Layer<
|
|
1322
|
+
BrowserRunInteractiveHost,
|
|
1323
|
+
never,
|
|
1324
|
+
BrowserRunInteractiveBinding
|
|
1325
|
+
> =>
|
|
1326
|
+
Layer.effect(
|
|
1327
|
+
BrowserRunInteractiveHost,
|
|
1328
|
+
Effect.gen(function* () {
|
|
1329
|
+
return makeHostService(yield* BrowserRunInteractiveBinding);
|
|
1330
|
+
}),
|
|
1331
|
+
);
|
|
1332
|
+
|
|
1333
|
+
/** Worker-only generic adapter; Cloudflare identity and controls remain host-only. */
|
|
1334
|
+
export const browserRunInteractiveLayer = (): Layer.Layer<
|
|
1335
|
+
InteractiveBrowser,
|
|
1336
|
+
never,
|
|
1337
|
+
BrowserRunInteractiveBinding
|
|
1338
|
+
> =>
|
|
1339
|
+
Layer.effect(
|
|
1340
|
+
InteractiveBrowser,
|
|
1341
|
+
Effect.gen(function* () {
|
|
1342
|
+
const binding = yield* BrowserRunInteractiveBinding;
|
|
1343
|
+
const host = makeHostService(binding);
|
|
1344
|
+
return InteractiveBrowser.of({
|
|
1345
|
+
open: (policy) => host.open(policy).pipe(Effect.map((session) => session.handle)),
|
|
746
1346
|
});
|
|
747
1347
|
}),
|
|
748
1348
|
);
|