@effect-agent/platform-cloudflare 0.1.0-beta.30 → 0.1.0-beta.32

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.
@@ -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 BoundedRemoteText = Schema.String.check(Schema.isMaxLength(8 * 1024 * 1024));
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)({ launch: async (keepAliveMillis) => makeProductionBrowser(await puppeteer.launch(options.browser, { keep_alive: keepAliveMillis })) });
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 {
@@ -85,7 +149,20 @@ const makeProductionPage = (page) => {
85
149
  }
86
150
  }, value);
87
151
  },
88
- click: (selector) => page.click(selector)
152
+ click: (selector) => page.click(selector),
153
+ screenshot: (fullPage) => page.screenshot({
154
+ type: "png",
155
+ fullPage
156
+ }),
157
+ scroll: (deltaX, deltaY) => page.evaluate((x, y) => {
158
+ const scrollBy = Reflect.get(globalThis, "scrollBy");
159
+ Reflect.apply(scrollBy, globalThis, [{
160
+ left: x,
161
+ top: y,
162
+ behavior: "instant"
163
+ }]);
164
+ }, deltaX, deltaY),
165
+ createCdpSession: async () => makeProductionCdpSession(await page.createCDPSession())
89
166
  };
90
167
  };
91
168
  const makeProductionContext = (context) => ({
@@ -95,6 +172,7 @@ const makeProductionContext = (context) => ({
95
172
  const makeProductionBrowser = (browser) => ({
96
173
  createContext: async () => makeProductionContext(await browser.createBrowserContext()),
97
174
  close: () => browser.close(),
175
+ sessionId: () => browser.sessionId(),
98
176
  isConnected: () => browser.isConnected(),
99
177
  onDisconnected: (listener) => {
100
178
  browser.on("disconnected", listener);
@@ -143,18 +221,21 @@ const hostAllowed = (policy, value) => {
143
221
  }
144
222
  };
145
223
  const keepAliveMillis = (policy) => Math.max(MIN_KEEP_ALIVE_MILLIS, Math.min(MAX_KEEP_ALIVE_MILLIS, policy.maxElapsedMillis));
146
- const closeLateAcquisition = async (signal, acquire) => {
224
+ const closeLateAcquisition = async (signal, acquire, close) => {
147
225
  const acquired = await acquire();
148
226
  if (!signal.aborted) return acquired;
149
227
  try {
150
- await acquired.close();
228
+ await close(acquired);
151
229
  } catch {}
152
230
  throw new Error("The interrupted browser acquisition completed late");
153
231
  };
154
232
  const closeWithWarning = (close, warning) => Effect.tryPromise({
155
233
  try: close,
156
234
  catch: () => protocolError(warning)
157
- }).pipe(Effect.catchCause(() => Effect.logWarning(warning)));
235
+ }).pipe(Effect.timeoutOrElse({
236
+ duration: Duration.millis(CLEANUP_STEP_TIMEOUT_MILLIS),
237
+ orElse: () => Effect.fail(protocolError(warning))
238
+ }), Effect.catchCause(() => Effect.logWarning(warning)));
158
239
  const deadlineError = Effect.fn("BrowserRunInteractive.deadlineError")(function* (policy, startedAt) {
159
240
  const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
160
241
  return yield* InteractiveBrowserLimitError.make({
@@ -177,7 +258,7 @@ const withinDeadline = Effect.fn("BrowserRunInteractive.withinDeadline")(functio
177
258
  });
178
259
  const stateFailure = (state) => {
179
260
  if (state.violation.value !== void 0) return state.violation.value;
180
- if (state.disconnected.value || state.uncertain.value) return expiredError();
261
+ if (state.closed.value || state.disconnected.value || state.uncertain.value) return expiredError();
181
262
  };
182
263
  const awaitPendingRequests = (state) => Effect.suspend(() => {
183
264
  const pending = [...state.pendingRequests];
@@ -223,7 +304,7 @@ const makeRequestListener = (policy, state) => (request) => {
223
304
  });
224
305
  state.pendingRequests.add(observed);
225
306
  };
226
- const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (page, policy, startedAt, state) {
307
+ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (page, policy, startedAt, state, close) {
227
308
  const permits = yield* Semaphore.make(1);
228
309
  const actions = yield* Ref.make(0);
229
310
  const remote = (operation, evaluate) => Effect.tryPromise({
@@ -275,65 +356,131 @@ const makeHandle = Effect.fn("BrowserRunInteractive.makeHandle")(function* (page
275
356
  message: "The browser handle already has an operation in flight"
276
357
  }))));
277
358
  return {
278
- navigate: (request) => run(Effect.gen(function* () {
279
- yield* remote("navigate", () => page.goto(request.url));
280
- return yield* decodeNavigationResult(page, policy);
281
- }), Effect.suspend(() => hostAllowed(policy, request.url) ? Effect.void : Effect.fail(policyError("The navigation URL is outside the browser policy")))),
282
- readText: (request) => run(Effect.gen(function* () {
283
- const raw = yield* remote("read-text", () => page.readText(request.selector, policy.maxReturnedBytes));
284
- const observation = yield* Schema.decodeUnknownEffect(TextObservation)(raw).pipe(Effect.mapError((cause) => protocolError("The browser returned a malformed text observation", cause)));
285
- if (observation._tag === "MissingElement") return yield* actionError("read-text");
286
- if (observation._tag === "OverLimit") return yield* InteractiveBrowserLimitError.make({
287
- implementation: browserRunInteractiveImplementation,
288
- limit: "returned-bytes",
289
- maximum: policy.maxReturnedBytes,
290
- observed: observation.observed,
291
- message: "The browser returned-text limit was reached"
292
- });
293
- const observed = new TextEncoder().encode(observation.text).byteLength;
294
- if (observed > policy.maxReturnedBytes) return yield* InteractiveBrowserLimitError.make({
295
- implementation: browserRunInteractiveImplementation,
296
- limit: "returned-bytes",
297
- maximum: policy.maxReturnedBytes,
298
- observed,
299
- message: "The browser returned-text limit was reached"
300
- });
301
- return yield* Schema.decodeUnknownEffect(BrowserTextResult)({ text: observation.text }).pipe(Effect.mapError((cause) => protocolError("The browser returned malformed page text", cause)));
302
- })),
303
- fill: (request) => run(remote("fill", () => page.fill(request.selector, request.value)).pipe(Effect.andThen(decodeActionResult(page, policy)))),
304
- click: (request) => run(remote("click", () => page.click(request.selector)).pipe(Effect.andThen(decodeActionResult(page, policy))))
359
+ handle: {
360
+ navigate: (request) => run(Effect.gen(function* () {
361
+ yield* remote("navigate", () => page.goto(request.url));
362
+ return yield* decodeNavigationResult(page, policy);
363
+ }), Effect.suspend(() => hostAllowed(policy, request.url) ? Effect.void : Effect.fail(policyError("The navigation URL is outside the browser policy")))),
364
+ readText: (request) => run(Effect.gen(function* () {
365
+ const raw = yield* remote("read-text", () => page.readText(request.selector, policy.maxReturnedBytes));
366
+ const observation = yield* Schema.decodeUnknownEffect(TextObservation)(raw).pipe(Effect.mapError((cause) => protocolError("The browser returned a malformed text observation", cause)));
367
+ if (observation._tag === "MissingElement") return yield* actionError("read-text");
368
+ if (observation._tag === "OverLimit") return yield* InteractiveBrowserLimitError.make({
369
+ implementation: browserRunInteractiveImplementation,
370
+ limit: "returned-bytes",
371
+ maximum: policy.maxReturnedBytes,
372
+ observed: observation.observed,
373
+ message: "The browser returned-text limit was reached"
374
+ });
375
+ const observed = new TextEncoder().encode(observation.text).byteLength;
376
+ if (observed > policy.maxReturnedBytes) return yield* InteractiveBrowserLimitError.make({
377
+ implementation: browserRunInteractiveImplementation,
378
+ limit: "returned-bytes",
379
+ maximum: policy.maxReturnedBytes,
380
+ observed,
381
+ message: "The browser returned-text limit was reached"
382
+ });
383
+ return yield* Schema.decodeUnknownEffect(BrowserTextResult)({ text: observation.text }).pipe(Effect.mapError((cause) => protocolError("The browser returned malformed page text", cause)));
384
+ })),
385
+ fill: (request) => run(remote("fill", () => page.fill(request.selector, request.value)).pipe(Effect.andThen(decodeActionResult(page, policy)))),
386
+ click: (request) => run(remote("click", () => page.click(request.selector)).pipe(Effect.andThen(decodeActionResult(page, policy)))),
387
+ screenshot: (request) => Schema.decodeUnknownEffect(BrowserScreenshotRequest)(request).pipe(Effect.mapError(() => policyError("The browser screenshot request is malformed")), Effect.flatMap((decoded) => run(Effect.gen(function* () {
388
+ const raw = yield* remote("screenshot", () => page.screenshot(decoded.fullPage));
389
+ const bytes = yield* Schema.decodeUnknownEffect(PngBytes)(raw).pipe(Effect.mapError(() => protocolError("The browser returned a malformed PNG screenshot")));
390
+ if (bytes.length > policy.maxReturnedBytes) return yield* InteractiveBrowserLimitError.make({
391
+ implementation: browserRunInteractiveImplementation,
392
+ limit: "returned-bytes",
393
+ maximum: policy.maxReturnedBytes,
394
+ observed: bytes.length,
395
+ message: "The browser screenshot byte limit was reached"
396
+ });
397
+ return yield* Schema.decodeUnknownEffect(PageScreenshotResult)({
398
+ implementation: browserRunInteractiveImplementation,
399
+ mediaType: "image/png",
400
+ bytes: new Uint8Array(bytes)
401
+ }).pipe(Effect.mapError(() => protocolError("The browser returned a malformed PNG screenshot")));
402
+ }), decodeActionResult(page, policy).pipe(Effect.asVoid)))),
403
+ 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)))),
404
+ close
405
+ },
406
+ run
305
407
  };
306
408
  });
307
- /** Worker-only Cloudflare Puppeteer adapter; the caller supplies the Browser Run binding Layer. */
308
- const browserRunInteractiveLayer = () => Layer.effect(InteractiveBrowser, Effect.gen(function* () {
309
- const binding = yield* BrowserRunInteractiveBinding;
310
- return InteractiveBrowser.of({ open: (policy) => Effect.gen(function* () {
409
+ const remainingMillis = Effect.fn("BrowserRunInteractive.remainingMillis")(function* (policy, startedAt) {
410
+ const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
411
+ return Math.max(0, policy.maxElapsedMillis - Math.max(0, now - startedAt));
412
+ });
413
+ const closeEntry = (close, warning) => ({
414
+ close: Effect.tryPromise({
415
+ try: close,
416
+ catch: (cause) => actionError("close", cause)
417
+ }).pipe(Effect.timeoutOrElse({
418
+ duration: Duration.millis(CLEANUP_STEP_TIMEOUT_MILLIS),
419
+ orElse: () => Effect.fail(actionError("close"))
420
+ })),
421
+ warning
422
+ });
423
+ const syncCloseEntry = (close, warning) => ({
424
+ close: Effect.try({
425
+ try: close,
426
+ catch: (cause) => actionError("close", cause)
427
+ }),
428
+ warning
429
+ });
430
+ const runTeardown = (entries) => Effect.forEach([...entries].reverse(), (entry) => entry.close.pipe(Effect.match({
431
+ onFailure: (error) => ({
432
+ error,
433
+ warning: entry.warning
434
+ }),
435
+ onSuccess: () => void 0
436
+ }))).pipe(Effect.map((failures) => failures.filter((failure) => failure !== void 0)));
437
+ const cdpCommand = (page, state, command, parameters, output, malformedMessage) => Effect.scoped(Effect.gen(function* () {
438
+ const cdp = yield* Effect.acquireRelease(Effect.tryPromise({
439
+ try: (signal) => closeLateAcquisition(signal, page.createCdpSession, (acquired) => acquired.detach()),
440
+ catch: (cause) => state.disconnected.value || isRemoteClosure(cause) ? expiredError() : protocolError("Creating the Cloudflare browser control session failed", cause)
441
+ }), (acquired) => closeWithWarning(acquired.detach, "Detaching the Cloudflare browser control session failed"), { interruptible: true });
442
+ const raw = yield* Effect.tryPromise({
443
+ try: () => cdp.send(command, parameters),
444
+ catch: (cause) => state.disconnected.value || isRemoteClosure(cause) ? expiredError() : protocolError("The Cloudflare browser control command failed", cause)
445
+ });
446
+ return yield* Schema.decodeUnknownEffect(output)(raw).pipe(Effect.mapError(() => protocolError(malformedMessage)));
447
+ }));
448
+ const makeHostService = (binding) => {
449
+ const open = Effect.fn("BrowserRunInteractiveHost.open")(function* (policy) {
311
450
  const fixedPolicy = yield* snapshotPolicy(policy);
312
451
  const startedAt = yield* Effect.clockWith((clock) => clock.currentTimeMillis);
313
452
  const state = {
453
+ closed: { value: false },
314
454
  disconnected: { value: false },
315
455
  uncertain: { value: false },
316
456
  violation: { value: void 0 },
317
457
  pendingRequests: /* @__PURE__ */ new Set()
318
458
  };
459
+ const lifecycle = {
460
+ managedTeardownInstalled: false,
461
+ explicitCloseInvoked: false
462
+ };
463
+ const closers = [];
464
+ const releaseBeforeManaged = (entry) => Effect.suspend(() => lifecycle.managedTeardownInstalled ? Effect.void : entry.close.pipe(Effect.catchCause(() => Effect.logWarning(entry.warning))));
319
465
  const browser = yield* Effect.acquireRelease(withinDeadline(Effect.tryPromise({
320
- try: (signal) => closeLateAcquisition(signal, () => binding.launch(keepAliveMillis(fixedPolicy))),
466
+ try: (signal) => closeLateAcquisition(signal, () => binding.launch(keepAliveMillis(fixedPolicy)), (acquired) => acquired.close()),
321
467
  catch: (cause) => isCapacityRefusal(cause) ? InteractiveBrowserCapacityError.make({
322
468
  implementation: browserRunInteractiveImplementation,
323
469
  message: "Browser Run has no capacity for a new browser session"
324
470
  }) : protocolError("Launching the Browser Run session failed", cause)
325
- }), fixedPolicy, startedAt), (acquired) => Effect.sync(() => {
471
+ }), fixedPolicy, startedAt), (acquired) => {
326
472
  state.disconnected.value = true;
327
- }).pipe(Effect.andThen(closeWithWarning(acquired.close, "Closing the interactive browser failed"))), { interruptible: true });
473
+ return releaseBeforeManaged(closeEntry(acquired.close, "Closing the interactive browser failed"));
474
+ }, { interruptible: true });
475
+ closers.push(closeEntry(browser.close, "Closing the interactive browser failed"));
328
476
  const disconnected = () => {
329
477
  state.disconnected.value = true;
330
478
  };
331
479
  yield* Effect.acquireRelease(Effect.try({
332
480
  try: () => browser.onDisconnected(disconnected),
333
481
  catch: (cause) => protocolError("Installing the browser disconnect listener failed", cause)
334
- }), () => Effect.sync(() => {
335
- browser.offDisconnected(disconnected);
336
- }).pipe(Effect.catchCause(() => Effect.logWarning("Removing the browser disconnect listener failed"))));
482
+ }), () => releaseBeforeManaged(syncCloseEntry(() => browser.offDisconnected(disconnected), "Removing the browser disconnect listener failed")));
483
+ closers.push(syncCloseEntry(() => browser.offDisconnected(disconnected), "Removing the browser disconnect listener failed"));
337
484
  if (!(yield* Effect.try({
338
485
  try: browser.isConnected,
339
486
  catch: (cause) => protocolError("Reading the Browser Run connection state failed", cause)
@@ -341,14 +488,20 @@ const browserRunInteractiveLayer = () => Layer.effect(InteractiveBrowser, Effect
341
488
  state.disconnected.value = true;
342
489
  return yield* expiredError();
343
490
  }
491
+ const sessionIdValue = yield* Effect.try({
492
+ try: browser.sessionId,
493
+ catch: (cause) => protocolError("Reading the Browser Run session identity failed", cause)
494
+ }).pipe(Effect.flatMap((value) => Schema.decodeUnknownEffect(BrowserRunSessionId)(value).pipe(Effect.mapError(() => protocolError("The Browser Run session identity was malformed")))));
344
495
  const context = yield* Effect.acquireRelease(withinDeadline(Effect.tryPromise({
345
- try: (signal) => closeLateAcquisition(signal, browser.createContext),
496
+ try: (signal) => closeLateAcquisition(signal, browser.createContext, (acquired) => acquired.close()),
346
497
  catch: (cause) => state.disconnected.value || isRemoteClosure(cause) ? expiredError() : protocolError("Creating the browser context failed", cause)
347
- }), fixedPolicy, startedAt), (acquired) => closeWithWarning(acquired.close, "Closing the interactive browser context failed"), { interruptible: true });
498
+ }), fixedPolicy, startedAt), (acquired) => releaseBeforeManaged(closeEntry(acquired.close, "Closing the interactive browser context failed")), { interruptible: true });
499
+ closers.push(closeEntry(context.close, "Closing the interactive browser context failed"));
348
500
  const page = yield* Effect.acquireRelease(withinDeadline(Effect.tryPromise({
349
- try: (signal) => closeLateAcquisition(signal, context.newPage),
501
+ try: (signal) => closeLateAcquisition(signal, context.newPage, (acquired) => acquired.close()),
350
502
  catch: (cause) => state.disconnected.value || isRemoteClosure(cause) ? expiredError() : protocolError("Creating the browser page failed", cause)
351
- }), fixedPolicy, startedAt), (acquired) => closeWithWarning(acquired.close, "Closing the interactive browser page failed"), { interruptible: true });
503
+ }), fixedPolicy, startedAt), (acquired) => releaseBeforeManaged(closeEntry(acquired.close, "Closing the interactive browser page failed")), { interruptible: true });
504
+ closers.push(closeEntry(page.close, "Closing the interactive browser page failed"));
352
505
  yield* withinDeadline(Effect.tryPromise({
353
506
  try: () => page.setBypassServiceWorker(true),
354
507
  catch: (cause) => protocolError("Bypassing browser service workers failed", cause)
@@ -357,9 +510,8 @@ const browserRunInteractiveLayer = () => Layer.effect(InteractiveBrowser, Effect
357
510
  yield* Effect.acquireRelease(Effect.try({
358
511
  try: () => page.onRequest(requestListener),
359
512
  catch: (cause) => protocolError("Installing the browser request listener failed", cause)
360
- }), () => Effect.sync(() => {
361
- page.offRequest(requestListener);
362
- }).pipe(Effect.catchCause(() => Effect.logWarning("Removing the browser request policy failed"))));
513
+ }), () => releaseBeforeManaged(syncCloseEntry(() => page.offRequest(requestListener), "Removing the browser request policy failed")));
514
+ closers.push(syncCloseEntry(() => page.offRequest(requestListener), "Removing the browser request policy failed"));
363
515
  yield* withinDeadline(Effect.tryPromise({
364
516
  try: () => page.setRequestInterception(true),
365
517
  catch: (cause) => protocolError("Installing the browser request policy failed", cause)
@@ -367,10 +519,78 @@ const browserRunInteractiveLayer = () => Layer.effect(InteractiveBrowser, Effect
367
519
  yield* withinDeadline(awaitPendingRequests(state), fixedPolicy, startedAt);
368
520
  const setupFailure = stateFailure(state);
369
521
  if (setupFailure !== void 0) return yield* setupFailure;
370
- return yield* makeHandle(page, fixedPolicy, startedAt, state);
371
- }) });
522
+ const teardown = yield* Effect.uninterruptible(Effect.gen(function* () {
523
+ const cached = yield* Effect.cached(runTeardown(closers));
524
+ lifecycle.managedTeardownInstalled = true;
525
+ yield* Effect.addFinalizer(() => Effect.uninterruptible(Effect.sync(() => {
526
+ state.closed.value = true;
527
+ state.disconnected.value = true;
528
+ }).pipe(Effect.andThen(cached), Effect.flatMap((failures) => lifecycle.explicitCloseInvoked ? Effect.void : Effect.forEach(failures, (failure) => Effect.logWarning(failure.warning)).pipe(Effect.asVoid)))));
529
+ return cached;
530
+ }));
531
+ const close = Effect.uninterruptible(Effect.sync(() => {
532
+ lifecycle.explicitCloseInvoked = true;
533
+ state.closed.value = true;
534
+ state.disconnected.value = true;
535
+ }).pipe(Effect.andThen(teardown), Effect.flatMap((failures) => failures[0] === void 0 ? Effect.void : Effect.fail(failures[0].error))));
536
+ const runtime = yield* makeHandle(page, fixedPolicy, startedAt, state, close);
537
+ const currentPagePreflight = decodeActionResult(page, fixedPolicy).pipe(Effect.asVoid);
538
+ 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"))));
539
+ return {
540
+ handle: runtime.handle,
541
+ sessionId: Redacted.make(sessionIdValue),
542
+ 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", {
543
+ mode: decoded.mode,
544
+ expiresInMs: decoded.expiresInMs
545
+ }, 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)))))),
546
+ 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", {
547
+ instructions: decoded.instructions,
548
+ timeout: decoded.timeout
549
+ }, 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)))))),
550
+ getHandoffState: runtime.run(cdpCommand(page, state, "Cloudflare.getHandoffState", {}, HandoffStateObservation, "Cloudflare returned a malformed browser handoff state").pipe(Effect.flatMap((observation) => Schema.decodeUnknownEffect(BrowserRunHandoffState)({
551
+ active: observation.active,
552
+ ...observation.handoffId === void 0 ? {} : { handoffId: Redacted.make(observation.handoffId) },
553
+ ...observation.durationMs === void 0 ? {} : { durationMs: observation.durationMs }
554
+ }).pipe(Effect.mapError(() => protocolError("Cloudflare returned a malformed browser handoff state"))))), currentPagePreflight),
555
+ close
556
+ };
557
+ });
558
+ const closeSession = Effect.fn("BrowserRunInteractiveHost.closeSession")(function* (sessionId) {
559
+ const decoded = yield* Schema.decodeUnknownEffect(Schema.Redacted(BrowserRunSessionId))(sessionId).pipe(Effect.mapError(() => policyError("The Browser Run cleanup session identity is malformed")));
560
+ return yield* Effect.scoped(Effect.gen(function* () {
561
+ const closeAttempted = { value: false };
562
+ const browser = yield* Effect.acquireRelease(Effect.tryPromise({
563
+ try: (signal) => closeLateAcquisition(signal, () => binding.connect(Redacted.value(decoded)), (acquired) => acquired.close()),
564
+ catch: (cause) => actionError("close", cause)
565
+ }), (acquired) => closeAttempted.value ? Effect.void : closeWithWarning(acquired.close, "Closing the leaked Browser Run session failed"), { interruptible: true });
566
+ return yield* Effect.tryPromise({
567
+ try: () => {
568
+ closeAttempted.value = true;
569
+ return browser.close();
570
+ },
571
+ catch: (cause) => actionError("close", cause)
572
+ });
573
+ })).pipe(Effect.timeoutOrElse({
574
+ duration: Duration.millis(CLOSE_SESSION_TIMEOUT_MILLIS),
575
+ orElse: () => Effect.fail(actionError("close"))
576
+ }));
577
+ });
578
+ return BrowserRunInteractiveHost.of({
579
+ open,
580
+ closeSession
581
+ });
582
+ };
583
+ /** Cloudflare host controls and private session identity for one scoped Browser Run pass. */
584
+ const browserRunInteractiveHostLayer = () => Layer.effect(BrowserRunInteractiveHost, Effect.gen(function* () {
585
+ return makeHostService(yield* BrowserRunInteractiveBinding);
586
+ }));
587
+ /** Worker-only generic adapter; Cloudflare identity and controls remain host-only. */
588
+ const browserRunInteractiveLayer = () => Layer.effect(InteractiveBrowser, Effect.gen(function* () {
589
+ const binding = yield* BrowserRunInteractiveBinding;
590
+ const host = makeHostService(binding);
591
+ return InteractiveBrowser.of({ open: (policy) => host.open(policy).pipe(Effect.map((session) => session.handle)) });
372
592
  }));
373
593
  //#endregion
374
- export { BrowserRunInteractiveBinding, browserRunInteractiveImplementation, browserRunInteractiveLayer };
594
+ export { BrowserRunHandoffRequest, BrowserRunHandoffResult, BrowserRunHandoffState, BrowserRunInteractiveBinding, BrowserRunInteractiveHost, BrowserRunLiveViewRequest, BrowserRunLiveViewResult, browserRunInteractiveHostLayer, browserRunInteractiveImplementation, browserRunInteractiveLayer };
375
595
 
376
596
  //# sourceMappingURL=interactive-browser.mjs.map