@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.
@@ -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 {
@@ -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
- if (!("value" in element)) throw new Error("The selector did not resolve to a fillable field");
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.set(element, "value", nextValue);
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 acquired.close();
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.catchCause(() => Effect.logWarning(warning)));
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
- 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))))
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
- /** 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* () {
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) => Effect.sync(() => {
481
+ }), fixedPolicy, startedAt), (acquired) => {
326
482
  state.disconnected.value = true;
327
- }).pipe(Effect.andThen(closeWithWarning(acquired.close, "Closing the interactive browser failed"))), { interruptible: true });
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
- }), () => Effect.sync(() => {
335
- browser.offDisconnected(disconnected);
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) => closeWithWarning(acquired.close, "Closing the interactive browser context failed"), { interruptible: true });
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) => closeWithWarning(acquired.close, "Closing the interactive browser page failed"), { interruptible: true });
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
- }), () => Effect.sync(() => {
361
- page.offRequest(requestListener);
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
- return yield* makeHandle(page, fixedPolicy, startedAt, state);
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