@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
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Context, Duration, Effect, Layer, Option, Ref, Schema, Semaphore } from "effect";
|
|
2
|
-
import { BrowserActionResult, BrowserNavigationResult, BrowserTextResult, InteractiveBrowser, InteractiveBrowserActionError, InteractiveBrowserBusyError, InteractiveBrowserCapacityError, InteractiveBrowserExpiredError, InteractiveBrowserLimitError, InteractiveBrowserPolicy, InteractiveBrowserPolicyDeniedError, InteractiveBrowserProtocolError, SandboxImplementation } from "@effect-agent/sandbox";
|
|
1
|
+
import { Context, Duration, Effect, Layer, Option, Redacted, Ref, Schema, Semaphore } from "effect";
|
|
2
|
+
import { BrowserActionResult, BrowserNavigationResult, BrowserScreenshotRequest, BrowserScrollRequest, BrowserTextResult, InteractiveBrowser, InteractiveBrowserActionError, InteractiveBrowserBusyError, InteractiveBrowserCapacityError, InteractiveBrowserExpiredError, InteractiveBrowserLimitError, InteractiveBrowserPolicy, InteractiveBrowserPolicyDeniedError, InteractiveBrowserProtocolError, PageScreenshotResult, SandboxImplementation } from "@effect-agent/sandbox";
|
|
3
3
|
import puppeteer from "@cloudflare/puppeteer";
|
|
4
4
|
//#region src/interactive-browser.ts
|
|
5
5
|
const browserRunInteractiveImplementation = SandboxImplementation.make({
|
|
@@ -8,7 +8,17 @@ const browserRunInteractiveImplementation = SandboxImplementation.make({
|
|
|
8
8
|
});
|
|
9
9
|
const MIN_KEEP_ALIVE_MILLIS = 1e4;
|
|
10
10
|
const MAX_KEEP_ALIVE_MILLIS = 6e5;
|
|
11
|
-
const
|
|
11
|
+
const MAX_TEXT_LENGTH = 8 * 1024 * 1024;
|
|
12
|
+
const MAX_SCREENSHOT_BYTES = 8 * 1024 * 1024;
|
|
13
|
+
const MIN_LIVE_VIEW_EXPIRY_MILLIS = 6e4;
|
|
14
|
+
const MAX_LIVE_VIEW_EXPIRY_MILLIS = 60 * 6e4;
|
|
15
|
+
const MAX_HANDOFF_TIMEOUT_MILLIS = 30 * 6e4;
|
|
16
|
+
const MAX_HOST_TEXT_LENGTH = 8 * 1024;
|
|
17
|
+
const CLEANUP_STEP_TIMEOUT_MILLIS = 1e4;
|
|
18
|
+
const CLOSE_SESSION_TIMEOUT_MILLIS = 1e4;
|
|
19
|
+
const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0));
|
|
20
|
+
const BoundedHostText = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(MAX_HOST_TEXT_LENGTH));
|
|
21
|
+
const BoundedRemoteText = Schema.String.check(Schema.isMaxLength(MAX_TEXT_LENGTH));
|
|
12
22
|
const TextObservation = Schema.Union([
|
|
13
23
|
Schema.Struct({
|
|
14
24
|
_tag: Schema.Literal("Text"),
|
|
@@ -20,17 +30,71 @@ const TextObservation = Schema.Union([
|
|
|
20
30
|
observed: Schema.Natural
|
|
21
31
|
})
|
|
22
32
|
]);
|
|
33
|
+
const PngBytes = Schema.Uint8Array.check(Schema.isMaxLength(MAX_SCREENSHOT_BYTES), Schema.makeFilter((bytes) => bytes.length >= 8 && bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71 && bytes[4] === 13 && bytes[5] === 10 && bytes[6] === 26 && bytes[7] === 10, { title: "PNG bytes" }));
|
|
34
|
+
const BrowserRunSessionId = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256), Schema.makeFilter((value) => /^[A-Za-z0-9_-]+$/.test(value), { title: "a Browser Run session identifier" }));
|
|
35
|
+
const LiveViewUrl = Schema.String.check(Schema.isMaxLength(MAX_HOST_TEXT_LENGTH), Schema.makeFilter((value) => {
|
|
36
|
+
try {
|
|
37
|
+
const url = new URL(value);
|
|
38
|
+
return url.protocol === "https:" && url.host === "live.browser.run" && url.username === "" && url.password === "" && url.pathname === "/ui/view" && url.searchParams.get("mode") === "tab" && (url.searchParams.get("wss") ?? "").startsWith("live.browser.run/api/devtools/browser/");
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}, { title: "a Cloudflare Live View HTTPS URL" }));
|
|
43
|
+
const LiveViewObservation = Schema.Struct({ devtoolsFrontendUrl: LiveViewUrl });
|
|
44
|
+
const HandoffObservation = Schema.Struct({ handoffId: BoundedHostText });
|
|
45
|
+
const HandoffDuration = Schema.Natural.check(Schema.isLessThanOrEqualTo(MAX_HANDOFF_TIMEOUT_MILLIS));
|
|
46
|
+
const HandoffStateObservation = Schema.Union([Schema.Struct({
|
|
47
|
+
active: Schema.Literal(true),
|
|
48
|
+
handoffId: BoundedHostText,
|
|
49
|
+
durationMs: HandoffDuration
|
|
50
|
+
}), Schema.Struct({
|
|
51
|
+
active: Schema.Literal(false),
|
|
52
|
+
handoffId: Schema.optionalKey(BoundedHostText),
|
|
53
|
+
durationMs: Schema.optionalKey(HandoffDuration)
|
|
54
|
+
})]);
|
|
55
|
+
/** Host-only request for a redacted Cloudflare Live View URL. */
|
|
56
|
+
var BrowserRunLiveViewRequest = class extends Schema.Class("BrowserRunLiveViewRequest")({
|
|
57
|
+
mode: Schema.Literal("tab"),
|
|
58
|
+
expiresInMs: PositiveInt.check(Schema.isBetween({
|
|
59
|
+
minimum: MIN_LIVE_VIEW_EXPIRY_MILLIS,
|
|
60
|
+
maximum: MAX_LIVE_VIEW_EXPIRY_MILLIS
|
|
61
|
+
}))
|
|
62
|
+
}) {};
|
|
63
|
+
var BrowserRunLiveViewResult = class extends Schema.Class("BrowserRunLiveViewResult")({ devtoolsFrontendUrl: Schema.Redacted(LiveViewUrl) }) {};
|
|
64
|
+
/** Start one bounded handoff; controller ownership remains a consumer concern. */
|
|
65
|
+
var BrowserRunHandoffRequest = class extends Schema.Class("BrowserRunHandoffRequest")({
|
|
66
|
+
instructions: BoundedHostText.check(Schema.isMaxLength(1024)),
|
|
67
|
+
timeout: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_HANDOFF_TIMEOUT_MILLIS))
|
|
68
|
+
}) {};
|
|
69
|
+
var BrowserRunHandoffResult = class extends Schema.Class("BrowserRunHandoffResult")({ handoffId: Schema.Redacted(BoundedHostText) }) {};
|
|
70
|
+
var BrowserRunHandoffState = class extends Schema.Class("BrowserRunHandoffState")({
|
|
71
|
+
active: Schema.Boolean,
|
|
72
|
+
handoffId: Schema.optionalKey(Schema.Redacted(BoundedHostText)),
|
|
73
|
+
durationMs: Schema.optionalKey(HandoffDuration)
|
|
74
|
+
}) {};
|
|
23
75
|
/** Host-supplied Browser Run binding projected into one fakeable launch operation. */
|
|
24
76
|
var BrowserRunInteractiveBinding = class BrowserRunInteractiveBinding extends Context.Service()("@effect-agent/platform-cloudflare/BrowserRunInteractiveBinding") {
|
|
25
77
|
static layer(options) {
|
|
26
|
-
return Layer.succeed(BrowserRunInteractiveBinding)({
|
|
78
|
+
return Layer.succeed(BrowserRunInteractiveBinding)({
|
|
79
|
+
launch: async (keepAliveMillis) => makeProductionBrowser(await puppeteer.launch(options.browser, { keep_alive: keepAliveMillis })),
|
|
80
|
+
connect: async (sessionId) => makeProductionBrowser(await puppeteer.connect(options.browser, sessionId))
|
|
81
|
+
});
|
|
27
82
|
}
|
|
28
83
|
};
|
|
84
|
+
/** Cloudflare host authority kept separate from the provider-neutral browser handle. */
|
|
85
|
+
var BrowserRunInteractiveHost = class extends Context.Service()("@effect-agent/platform-cloudflare/BrowserRunInteractiveHost") {};
|
|
29
86
|
const makeProductionRequest = (request) => ({
|
|
30
87
|
url: () => request.url(),
|
|
31
88
|
abort: () => request.abort("blockedbyclient"),
|
|
32
89
|
continue: () => request.continue()
|
|
33
90
|
});
|
|
91
|
+
const makeProductionCdpSession = (session) => ({
|
|
92
|
+
send: async (command, parameters) => {
|
|
93
|
+
const send = Reflect.get(session, "send");
|
|
94
|
+
return await Reflect.apply(send, session, [command, parameters]);
|
|
95
|
+
},
|
|
96
|
+
detach: () => session.detach()
|
|
97
|
+
});
|
|
34
98
|
const makeProductionPage = (page) => {
|
|
35
99
|
const listeners = /* @__PURE__ */ new Map();
|
|
36
100
|
return {
|
|
@@ -74,10 +138,20 @@ const makeProductionPage = (page) => {
|
|
|
74
138
|
}, selector, maximumBytes),
|
|
75
139
|
fill: async (selector, value) => {
|
|
76
140
|
await page.$eval(selector, (element, nextValue) => {
|
|
77
|
-
|
|
141
|
+
let prototype = Reflect.getPrototypeOf(element);
|
|
142
|
+
let setValue;
|
|
143
|
+
while (prototype !== null) {
|
|
144
|
+
const setter = Reflect.getOwnPropertyDescriptor(prototype, "value")?.set;
|
|
145
|
+
if (typeof setter === "function") {
|
|
146
|
+
setValue = setter;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
prototype = Reflect.getPrototypeOf(prototype);
|
|
150
|
+
}
|
|
151
|
+
if (setValue === void 0) throw new Error("The selector did not resolve to a fillable field");
|
|
78
152
|
const focus = Reflect.get(element, "focus");
|
|
79
153
|
if (typeof focus === "function") Reflect.apply(focus, element, []);
|
|
80
|
-
Reflect.
|
|
154
|
+
Reflect.apply(setValue, element, [nextValue]);
|
|
81
155
|
const dispatchEvent = Reflect.get(element, "dispatchEvent");
|
|
82
156
|
if (typeof dispatchEvent === "function") {
|
|
83
157
|
Reflect.apply(dispatchEvent, element, [new Event("input", { bubbles: true })]);
|
|
@@ -85,7 +159,20 @@ const makeProductionPage = (page) => {
|
|
|
85
159
|
}
|
|
86
160
|
}, value);
|
|
87
161
|
},
|
|
88
|
-
click: (selector) => page.click(selector)
|
|
162
|
+
click: (selector) => page.click(selector),
|
|
163
|
+
screenshot: (fullPage) => page.screenshot({
|
|
164
|
+
type: "png",
|
|
165
|
+
fullPage
|
|
166
|
+
}),
|
|
167
|
+
scroll: (deltaX, deltaY) => page.evaluate((x, y) => {
|
|
168
|
+
const scrollBy = Reflect.get(globalThis, "scrollBy");
|
|
169
|
+
Reflect.apply(scrollBy, globalThis, [{
|
|
170
|
+
left: x,
|
|
171
|
+
top: y,
|
|
172
|
+
behavior: "instant"
|
|
173
|
+
}]);
|
|
174
|
+
}, deltaX, deltaY),
|
|
175
|
+
createCdpSession: async () => makeProductionCdpSession(await page.createCDPSession())
|
|
89
176
|
};
|
|
90
177
|
};
|
|
91
178
|
const makeProductionContext = (context) => ({
|
|
@@ -95,6 +182,7 @@ const makeProductionContext = (context) => ({
|
|
|
95
182
|
const makeProductionBrowser = (browser) => ({
|
|
96
183
|
createContext: async () => makeProductionContext(await browser.createBrowserContext()),
|
|
97
184
|
close: () => browser.close(),
|
|
185
|
+
sessionId: () => browser.sessionId(),
|
|
98
186
|
isConnected: () => browser.isConnected(),
|
|
99
187
|
onDisconnected: (listener) => {
|
|
100
188
|
browser.on("disconnected", listener);
|
|
@@ -143,18 +231,21 @@ const hostAllowed = (policy, value) => {
|
|
|
143
231
|
}
|
|
144
232
|
};
|
|
145
233
|
const keepAliveMillis = (policy) => Math.max(MIN_KEEP_ALIVE_MILLIS, Math.min(MAX_KEEP_ALIVE_MILLIS, policy.maxElapsedMillis));
|
|
146
|
-
const closeLateAcquisition = async (signal, acquire) => {
|
|
234
|
+
const closeLateAcquisition = async (signal, acquire, close) => {
|
|
147
235
|
const acquired = await acquire();
|
|
148
236
|
if (!signal.aborted) return acquired;
|
|
149
237
|
try {
|
|
150
|
-
await
|
|
238
|
+
await close(acquired);
|
|
151
239
|
} catch {}
|
|
152
240
|
throw new Error("The interrupted browser acquisition completed late");
|
|
153
241
|
};
|
|
154
242
|
const closeWithWarning = (close, warning) => Effect.tryPromise({
|
|
155
243
|
try: close,
|
|
156
244
|
catch: () => protocolError(warning)
|
|
157
|
-
}).pipe(Effect.
|
|
245
|
+
}).pipe(Effect.timeoutOrElse({
|
|
246
|
+
duration: Duration.millis(CLEANUP_STEP_TIMEOUT_MILLIS),
|
|
247
|
+
orElse: () => Effect.fail(protocolError(warning))
|
|
248
|
+
}), Effect.catchCause(() => Effect.logWarning(warning)));
|
|
158
249
|
const deadlineError = Effect.fn("BrowserRunInteractive.deadlineError")(function* (policy, startedAt) {
|
|
159
250
|
const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
|
|
160
251
|
return yield* InteractiveBrowserLimitError.make({
|
|
@@ -177,7 +268,7 @@ const withinDeadline = Effect.fn("BrowserRunInteractive.withinDeadline")(functio
|
|
|
177
268
|
});
|
|
178
269
|
const stateFailure = (state) => {
|
|
179
270
|
if (state.violation.value !== void 0) return state.violation.value;
|
|
180
|
-
if (state.disconnected.value || state.uncertain.value) return expiredError();
|
|
271
|
+
if (state.closed.value || state.disconnected.value || state.uncertain.value) return expiredError();
|
|
181
272
|
};
|
|
182
273
|
const awaitPendingRequests = (state) => Effect.suspend(() => {
|
|
183
274
|
const pending = [...state.pendingRequests];
|
|
@@ -223,7 +314,7 @@ const makeRequestListener = (policy, state) => (request) => {
|
|
|
223
314
|
});
|
|
224
315
|
state.pendingRequests.add(observed);
|
|
225
316
|
};
|
|
226
|
-
const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (page, policy, startedAt, state) {
|
|
317
|
+
const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (page, policy, startedAt, state, close) {
|
|
227
318
|
const permits = yield* Semaphore.make(1);
|
|
228
319
|
const actions = yield* Ref.make(0);
|
|
229
320
|
const remote = (operation, evaluate) => Effect.tryPromise({
|
|
@@ -275,65 +366,131 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (page
|
|
|
275
366
|
message: "The browser handle already has an operation in flight"
|
|
276
367
|
}))));
|
|
277
368
|
return {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
369
|
+
handle: {
|
|
370
|
+
navigate: (request) => run(Effect.gen(function* () {
|
|
371
|
+
yield* remote("navigate", () => page.goto(request.url));
|
|
372
|
+
return yield* decodeNavigationResult(page, policy);
|
|
373
|
+
}), Effect.suspend(() => hostAllowed(policy, request.url) ? Effect.void : Effect.fail(policyError("The navigation URL is outside the browser policy")))),
|
|
374
|
+
readText: (request) => run(Effect.gen(function* () {
|
|
375
|
+
const raw = yield* remote("read-text", () => page.readText(request.selector, policy.maxReturnedBytes));
|
|
376
|
+
const observation = yield* Schema.decodeUnknownEffect(TextObservation)(raw).pipe(Effect.mapError((cause) => protocolError("The browser returned a malformed text observation", cause)));
|
|
377
|
+
if (observation._tag === "MissingElement") return yield* actionError("read-text");
|
|
378
|
+
if (observation._tag === "OverLimit") return yield* InteractiveBrowserLimitError.make({
|
|
379
|
+
implementation: browserRunInteractiveImplementation,
|
|
380
|
+
limit: "returned-bytes",
|
|
381
|
+
maximum: policy.maxReturnedBytes,
|
|
382
|
+
observed: observation.observed,
|
|
383
|
+
message: "The browser returned-text limit was reached"
|
|
384
|
+
});
|
|
385
|
+
const observed = new TextEncoder().encode(observation.text).byteLength;
|
|
386
|
+
if (observed > policy.maxReturnedBytes) return yield* InteractiveBrowserLimitError.make({
|
|
387
|
+
implementation: browserRunInteractiveImplementation,
|
|
388
|
+
limit: "returned-bytes",
|
|
389
|
+
maximum: policy.maxReturnedBytes,
|
|
390
|
+
observed,
|
|
391
|
+
message: "The browser returned-text limit was reached"
|
|
392
|
+
});
|
|
393
|
+
return yield* Schema.decodeUnknownEffect(BrowserTextResult)({ text: observation.text }).pipe(Effect.mapError((cause) => protocolError("The browser returned malformed page text", cause)));
|
|
394
|
+
})),
|
|
395
|
+
fill: (request) => run(remote("fill", () => page.fill(request.selector, request.value)).pipe(Effect.andThen(decodeActionResult(page, policy)))),
|
|
396
|
+
click: (request) => run(remote("click", () => page.click(request.selector)).pipe(Effect.andThen(decodeActionResult(page, policy)))),
|
|
397
|
+
screenshot: (request) => Schema.decodeUnknownEffect(BrowserScreenshotRequest)(request).pipe(Effect.mapError(() => policyError("The browser screenshot request is malformed")), Effect.flatMap((decoded) => run(Effect.gen(function* () {
|
|
398
|
+
const raw = yield* remote("screenshot", () => page.screenshot(decoded.fullPage));
|
|
399
|
+
const bytes = yield* Schema.decodeUnknownEffect(PngBytes)(raw).pipe(Effect.mapError(() => protocolError("The browser returned a malformed PNG screenshot")));
|
|
400
|
+
if (bytes.length > policy.maxReturnedBytes) return yield* InteractiveBrowserLimitError.make({
|
|
401
|
+
implementation: browserRunInteractiveImplementation,
|
|
402
|
+
limit: "returned-bytes",
|
|
403
|
+
maximum: policy.maxReturnedBytes,
|
|
404
|
+
observed: bytes.length,
|
|
405
|
+
message: "The browser screenshot byte limit was reached"
|
|
406
|
+
});
|
|
407
|
+
return yield* Schema.decodeUnknownEffect(PageScreenshotResult)({
|
|
408
|
+
implementation: browserRunInteractiveImplementation,
|
|
409
|
+
mediaType: "image/png",
|
|
410
|
+
bytes: new Uint8Array(bytes)
|
|
411
|
+
}).pipe(Effect.mapError(() => protocolError("The browser returned a malformed PNG screenshot")));
|
|
412
|
+
}), decodeActionResult(page, policy).pipe(Effect.asVoid)))),
|
|
413
|
+
scroll: (request) => Schema.decodeUnknownEffect(BrowserScrollRequest)(request).pipe(Effect.mapError(() => policyError("The browser scroll request is malformed")), Effect.flatMap((decoded) => run(remote("scroll", () => page.scroll(decoded.deltaX, decoded.deltaY)).pipe(Effect.andThen(decodeActionResult(page, policy))), decodeActionResult(page, policy).pipe(Effect.asVoid)))),
|
|
414
|
+
close
|
|
415
|
+
},
|
|
416
|
+
run
|
|
305
417
|
};
|
|
306
418
|
});
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
419
|
+
const remainingMillis = Effect.fn("BrowserRunInteractive.remainingMillis")(function* (policy, startedAt) {
|
|
420
|
+
const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
|
|
421
|
+
return Math.max(0, policy.maxElapsedMillis - Math.max(0, now - startedAt));
|
|
422
|
+
});
|
|
423
|
+
const closeEntry = (close, warning) => ({
|
|
424
|
+
close: Effect.tryPromise({
|
|
425
|
+
try: close,
|
|
426
|
+
catch: (cause) => actionError("close", cause)
|
|
427
|
+
}).pipe(Effect.timeoutOrElse({
|
|
428
|
+
duration: Duration.millis(CLEANUP_STEP_TIMEOUT_MILLIS),
|
|
429
|
+
orElse: () => Effect.fail(actionError("close"))
|
|
430
|
+
})),
|
|
431
|
+
warning
|
|
432
|
+
});
|
|
433
|
+
const syncCloseEntry = (close, warning) => ({
|
|
434
|
+
close: Effect.try({
|
|
435
|
+
try: close,
|
|
436
|
+
catch: (cause) => actionError("close", cause)
|
|
437
|
+
}),
|
|
438
|
+
warning
|
|
439
|
+
});
|
|
440
|
+
const runTeardown = (entries) => Effect.forEach([...entries].reverse(), (entry) => entry.close.pipe(Effect.match({
|
|
441
|
+
onFailure: (error) => ({
|
|
442
|
+
error,
|
|
443
|
+
warning: entry.warning
|
|
444
|
+
}),
|
|
445
|
+
onSuccess: () => void 0
|
|
446
|
+
}))).pipe(Effect.map((failures) => failures.filter((failure) => failure !== void 0)));
|
|
447
|
+
const cdpCommand = (page, state, command, parameters, output, malformedMessage) => Effect.scoped(Effect.gen(function* () {
|
|
448
|
+
const cdp = yield* Effect.acquireRelease(Effect.tryPromise({
|
|
449
|
+
try: (signal) => closeLateAcquisition(signal, page.createCdpSession, (acquired) => acquired.detach()),
|
|
450
|
+
catch: (cause) => state.disconnected.value || isRemoteClosure(cause) ? expiredError() : protocolError("Creating the Cloudflare browser control session failed", cause)
|
|
451
|
+
}), (acquired) => closeWithWarning(acquired.detach, "Detaching the Cloudflare browser control session failed"), { interruptible: true });
|
|
452
|
+
const raw = yield* Effect.tryPromise({
|
|
453
|
+
try: () => cdp.send(command, parameters),
|
|
454
|
+
catch: (cause) => state.disconnected.value || isRemoteClosure(cause) ? expiredError() : protocolError("The Cloudflare browser control command failed", cause)
|
|
455
|
+
});
|
|
456
|
+
return yield* Schema.decodeUnknownEffect(output)(raw).pipe(Effect.mapError(() => protocolError(malformedMessage)));
|
|
457
|
+
}));
|
|
458
|
+
const makeHostService = (binding) => {
|
|
459
|
+
const open = Effect.fn("BrowserRunInteractiveHost.open")(function* (policy) {
|
|
311
460
|
const fixedPolicy = yield* snapshotPolicy(policy);
|
|
312
461
|
const startedAt = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
|
|
313
462
|
const state = {
|
|
463
|
+
closed: { value: false },
|
|
314
464
|
disconnected: { value: false },
|
|
315
465
|
uncertain: { value: false },
|
|
316
466
|
violation: { value: void 0 },
|
|
317
467
|
pendingRequests: /* @__PURE__ */ new Set()
|
|
318
468
|
};
|
|
469
|
+
const lifecycle = {
|
|
470
|
+
managedTeardownInstalled: false,
|
|
471
|
+
explicitCloseInvoked: false
|
|
472
|
+
};
|
|
473
|
+
const closers = [];
|
|
474
|
+
const releaseBeforeManaged = (entry) => Effect.suspend(() => lifecycle.managedTeardownInstalled ? Effect.void : entry.close.pipe(Effect.catchCause(() => Effect.logWarning(entry.warning))));
|
|
319
475
|
const browser = yield* Effect.acquireRelease(withinDeadline(Effect.tryPromise({
|
|
320
|
-
try: (signal) => closeLateAcquisition(signal, () => binding.launch(keepAliveMillis(fixedPolicy))),
|
|
476
|
+
try: (signal) => closeLateAcquisition(signal, () => binding.launch(keepAliveMillis(fixedPolicy)), (acquired) => acquired.close()),
|
|
321
477
|
catch: (cause) => isCapacityRefusal(cause) ? InteractiveBrowserCapacityError.make({
|
|
322
478
|
implementation: browserRunInteractiveImplementation,
|
|
323
479
|
message: "Browser Run has no capacity for a new browser session"
|
|
324
480
|
}) : protocolError("Launching the Browser Run session failed", cause)
|
|
325
|
-
}), fixedPolicy, startedAt), (acquired) =>
|
|
481
|
+
}), fixedPolicy, startedAt), (acquired) => {
|
|
326
482
|
state.disconnected.value = true;
|
|
327
|
-
|
|
483
|
+
return releaseBeforeManaged(closeEntry(acquired.close, "Closing the interactive browser failed"));
|
|
484
|
+
}, { interruptible: true });
|
|
485
|
+
closers.push(closeEntry(browser.close, "Closing the interactive browser failed"));
|
|
328
486
|
const disconnected = () => {
|
|
329
487
|
state.disconnected.value = true;
|
|
330
488
|
};
|
|
331
489
|
yield* Effect.acquireRelease(Effect.try({
|
|
332
490
|
try: () => browser.onDisconnected(disconnected),
|
|
333
491
|
catch: (cause) => protocolError("Installing the browser disconnect listener failed", cause)
|
|
334
|
-
}), () =>
|
|
335
|
-
|
|
336
|
-
}).pipe(Effect.catchCause(() => Effect.logWarning("Removing the browser disconnect listener failed"))));
|
|
492
|
+
}), () => releaseBeforeManaged(syncCloseEntry(() => browser.offDisconnected(disconnected), "Removing the browser disconnect listener failed")));
|
|
493
|
+
closers.push(syncCloseEntry(() => browser.offDisconnected(disconnected), "Removing the browser disconnect listener failed"));
|
|
337
494
|
if (!(yield* Effect.try({
|
|
338
495
|
try: browser.isConnected,
|
|
339
496
|
catch: (cause) => protocolError("Reading the Browser Run connection state failed", cause)
|
|
@@ -341,14 +498,20 @@ const browserRunInteractiveLayer = () => Layer.effect(InteractiveBrowser, Effect
|
|
|
341
498
|
state.disconnected.value = true;
|
|
342
499
|
return yield* expiredError();
|
|
343
500
|
}
|
|
501
|
+
const sessionIdValue = yield* Effect.try({
|
|
502
|
+
try: browser.sessionId,
|
|
503
|
+
catch: (cause) => protocolError("Reading the Browser Run session identity failed", cause)
|
|
504
|
+
}).pipe(Effect.flatMap((value) => Schema.decodeUnknownEffect(BrowserRunSessionId)(value).pipe(Effect.mapError(() => protocolError("The Browser Run session identity was malformed")))));
|
|
344
505
|
const context = yield* Effect.acquireRelease(withinDeadline(Effect.tryPromise({
|
|
345
|
-
try: (signal) => closeLateAcquisition(signal, browser.createContext),
|
|
506
|
+
try: (signal) => closeLateAcquisition(signal, browser.createContext, (acquired) => acquired.close()),
|
|
346
507
|
catch: (cause) => state.disconnected.value || isRemoteClosure(cause) ? expiredError() : protocolError("Creating the browser context failed", cause)
|
|
347
|
-
}), fixedPolicy, startedAt), (acquired) =>
|
|
508
|
+
}), fixedPolicy, startedAt), (acquired) => releaseBeforeManaged(closeEntry(acquired.close, "Closing the interactive browser context failed")), { interruptible: true });
|
|
509
|
+
closers.push(closeEntry(context.close, "Closing the interactive browser context failed"));
|
|
348
510
|
const page = yield* Effect.acquireRelease(withinDeadline(Effect.tryPromise({
|
|
349
|
-
try: (signal) => closeLateAcquisition(signal, context.newPage),
|
|
511
|
+
try: (signal) => closeLateAcquisition(signal, context.newPage, (acquired) => acquired.close()),
|
|
350
512
|
catch: (cause) => state.disconnected.value || isRemoteClosure(cause) ? expiredError() : protocolError("Creating the browser page failed", cause)
|
|
351
|
-
}), fixedPolicy, startedAt), (acquired) =>
|
|
513
|
+
}), fixedPolicy, startedAt), (acquired) => releaseBeforeManaged(closeEntry(acquired.close, "Closing the interactive browser page failed")), { interruptible: true });
|
|
514
|
+
closers.push(closeEntry(page.close, "Closing the interactive browser page failed"));
|
|
352
515
|
yield* withinDeadline(Effect.tryPromise({
|
|
353
516
|
try: () => page.setBypassServiceWorker(true),
|
|
354
517
|
catch: (cause) => protocolError("Bypassing browser service workers failed", cause)
|
|
@@ -357,9 +520,8 @@ const browserRunInteractiveLayer = () => Layer.effect(InteractiveBrowser, Effect
|
|
|
357
520
|
yield* Effect.acquireRelease(Effect.try({
|
|
358
521
|
try: () => page.onRequest(requestListener),
|
|
359
522
|
catch: (cause) => protocolError("Installing the browser request listener failed", cause)
|
|
360
|
-
}), () =>
|
|
361
|
-
|
|
362
|
-
}).pipe(Effect.catchCause(() => Effect.logWarning("Removing the browser request policy failed"))));
|
|
523
|
+
}), () => releaseBeforeManaged(syncCloseEntry(() => page.offRequest(requestListener), "Removing the browser request policy failed")));
|
|
524
|
+
closers.push(syncCloseEntry(() => page.offRequest(requestListener), "Removing the browser request policy failed"));
|
|
363
525
|
yield* withinDeadline(Effect.tryPromise({
|
|
364
526
|
try: () => page.setRequestInterception(true),
|
|
365
527
|
catch: (cause) => protocolError("Installing the browser request policy failed", cause)
|
|
@@ -367,10 +529,78 @@ const browserRunInteractiveLayer = () => Layer.effect(InteractiveBrowser, Effect
|
|
|
367
529
|
yield* withinDeadline(awaitPendingRequests(state), fixedPolicy, startedAt);
|
|
368
530
|
const setupFailure = stateFailure(state);
|
|
369
531
|
if (setupFailure !== void 0) return yield* setupFailure;
|
|
370
|
-
|
|
371
|
-
|
|
532
|
+
const teardown = yield* Effect.uninterruptible(Effect.gen(function* () {
|
|
533
|
+
const cached = yield* Effect.cached(runTeardown(closers));
|
|
534
|
+
lifecycle.managedTeardownInstalled = true;
|
|
535
|
+
yield* Effect.addFinalizer(() => Effect.uninterruptible(Effect.sync(() => {
|
|
536
|
+
state.closed.value = true;
|
|
537
|
+
state.disconnected.value = true;
|
|
538
|
+
}).pipe(Effect.andThen(cached), Effect.flatMap((failures) => lifecycle.explicitCloseInvoked ? Effect.void : Effect.forEach(failures, (failure) => Effect.logWarning(failure.warning)).pipe(Effect.asVoid)))));
|
|
539
|
+
return cached;
|
|
540
|
+
}));
|
|
541
|
+
const close = Effect.uninterruptible(Effect.sync(() => {
|
|
542
|
+
lifecycle.explicitCloseInvoked = true;
|
|
543
|
+
state.closed.value = true;
|
|
544
|
+
state.disconnected.value = true;
|
|
545
|
+
}).pipe(Effect.andThen(teardown), Effect.flatMap((failures) => failures[0] === void 0 ? Effect.void : Effect.fail(failures[0].error))));
|
|
546
|
+
const runtime = yield* makeHandle(page, fixedPolicy, startedAt, state, close);
|
|
547
|
+
const currentPagePreflight = decodeActionResult(page, fixedPolicy).pipe(Effect.asVoid);
|
|
548
|
+
const requestFitsSession = (requestedMillis) => remainingMillis(fixedPolicy, startedAt).pipe(Effect.flatMap((remaining) => remaining > 0 && requestedMillis <= remaining ? Effect.void : Effect.fail(policyError("The host browser request exceeds the remaining session time"))));
|
|
549
|
+
return {
|
|
550
|
+
handle: runtime.handle,
|
|
551
|
+
sessionId: Redacted.make(sessionIdValue),
|
|
552
|
+
getLiveView: (request) => Schema.decodeUnknownEffect(BrowserRunLiveViewRequest)(request).pipe(Effect.mapError(() => policyError("The Live View request is malformed")), Effect.flatMap((decoded) => runtime.run(cdpCommand(page, state, "Cloudflare.getLiveView", {
|
|
553
|
+
mode: decoded.mode,
|
|
554
|
+
expiresInMs: decoded.expiresInMs
|
|
555
|
+
}, LiveViewObservation, "Cloudflare returned a malformed Live View response").pipe(Effect.flatMap((observation) => Schema.decodeUnknownEffect(BrowserRunLiveViewResult)({ devtoolsFrontendUrl: Redacted.make(observation.devtoolsFrontendUrl) }).pipe(Effect.mapError(() => protocolError("Cloudflare returned a malformed Live View response"))))), currentPagePreflight.pipe(Effect.andThen(requestFitsSession(decoded.expiresInMs)))))),
|
|
556
|
+
handoff: (request) => Schema.decodeUnknownEffect(BrowserRunHandoffRequest)(request).pipe(Effect.mapError(() => policyError("The browser handoff request is malformed")), Effect.flatMap((decoded) => runtime.run(cdpCommand(page, state, "Cloudflare.handoff", {
|
|
557
|
+
instructions: decoded.instructions,
|
|
558
|
+
timeout: decoded.timeout
|
|
559
|
+
}, HandoffObservation, "Cloudflare returned a malformed browser handoff response").pipe(Effect.flatMap((observation) => Schema.decodeUnknownEffect(BrowserRunHandoffResult)({ handoffId: Redacted.make(observation.handoffId) }).pipe(Effect.mapError(() => protocolError("Cloudflare returned a malformed browser handoff response"))))), currentPagePreflight.pipe(Effect.andThen(requestFitsSession(decoded.timeout)))))),
|
|
560
|
+
getHandoffState: runtime.run(cdpCommand(page, state, "Cloudflare.getHandoffState", {}, HandoffStateObservation, "Cloudflare returned a malformed browser handoff state").pipe(Effect.flatMap((observation) => Schema.decodeUnknownEffect(BrowserRunHandoffState)({
|
|
561
|
+
active: observation.active,
|
|
562
|
+
...observation.handoffId === void 0 ? {} : { handoffId: Redacted.make(observation.handoffId) },
|
|
563
|
+
...observation.durationMs === void 0 ? {} : { durationMs: observation.durationMs }
|
|
564
|
+
}).pipe(Effect.mapError(() => protocolError("Cloudflare returned a malformed browser handoff state"))))), currentPagePreflight),
|
|
565
|
+
close
|
|
566
|
+
};
|
|
567
|
+
});
|
|
568
|
+
const closeSession = Effect.fn("BrowserRunInteractiveHost.closeSession")(function* (sessionId) {
|
|
569
|
+
const decoded = yield* Schema.decodeUnknownEffect(Schema.Redacted(BrowserRunSessionId))(sessionId).pipe(Effect.mapError(() => policyError("The Browser Run cleanup session identity is malformed")));
|
|
570
|
+
return yield* Effect.scoped(Effect.gen(function* () {
|
|
571
|
+
const closeAttempted = { value: false };
|
|
572
|
+
const browser = yield* Effect.acquireRelease(Effect.tryPromise({
|
|
573
|
+
try: (signal) => closeLateAcquisition(signal, () => binding.connect(Redacted.value(decoded)), (acquired) => acquired.close()),
|
|
574
|
+
catch: (cause) => actionError("close", cause)
|
|
575
|
+
}), (acquired) => closeAttempted.value ? Effect.void : closeWithWarning(acquired.close, "Closing the leaked Browser Run session failed"), { interruptible: true });
|
|
576
|
+
return yield* Effect.tryPromise({
|
|
577
|
+
try: () => {
|
|
578
|
+
closeAttempted.value = true;
|
|
579
|
+
return browser.close();
|
|
580
|
+
},
|
|
581
|
+
catch: (cause) => actionError("close", cause)
|
|
582
|
+
});
|
|
583
|
+
})).pipe(Effect.timeoutOrElse({
|
|
584
|
+
duration: Duration.millis(CLOSE_SESSION_TIMEOUT_MILLIS),
|
|
585
|
+
orElse: () => Effect.fail(actionError("close"))
|
|
586
|
+
}));
|
|
587
|
+
});
|
|
588
|
+
return BrowserRunInteractiveHost.of({
|
|
589
|
+
open,
|
|
590
|
+
closeSession
|
|
591
|
+
});
|
|
592
|
+
};
|
|
593
|
+
/** Cloudflare host controls and private session identity for one scoped Browser Run pass. */
|
|
594
|
+
const browserRunInteractiveHostLayer = () => Layer.effect(BrowserRunInteractiveHost, Effect.gen(function* () {
|
|
595
|
+
return makeHostService(yield* BrowserRunInteractiveBinding);
|
|
596
|
+
}));
|
|
597
|
+
/** Worker-only generic adapter; Cloudflare identity and controls remain host-only. */
|
|
598
|
+
const browserRunInteractiveLayer = () => Layer.effect(InteractiveBrowser, Effect.gen(function* () {
|
|
599
|
+
const binding = yield* BrowserRunInteractiveBinding;
|
|
600
|
+
const host = makeHostService(binding);
|
|
601
|
+
return InteractiveBrowser.of({ open: (policy) => host.open(policy).pipe(Effect.map((session) => session.handle)) });
|
|
372
602
|
}));
|
|
373
603
|
//#endregion
|
|
374
|
-
export { BrowserRunInteractiveBinding, browserRunInteractiveImplementation, browserRunInteractiveLayer };
|
|
604
|
+
export { BrowserRunHandoffRequest, BrowserRunHandoffResult, BrowserRunHandoffState, BrowserRunInteractiveBinding, BrowserRunInteractiveHost, BrowserRunLiveViewRequest, BrowserRunLiveViewResult, browserRunInteractiveHostLayer, browserRunInteractiveImplementation, browserRunInteractiveLayer };
|
|
375
605
|
|
|
376
606
|
//# sourceMappingURL=interactive-browser.mjs.map
|