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