1import type * as cf from "@cloudflare/workers-types";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import type { RuntimeContext } from "../../RuntimeContext.ts";
5import * as Binding from "./Binding.ts";
6import { makeBindingLayer } from "./BindingLayer.ts";
7import {
8 Browser,
9 type BrowserClient,
10 BrowserError,
11 type BrowserResponse,
12} from "./Browser.ts";
13
14/** The binding value produced by calling {@link Browser} (declared on `env` or `yield*`-ed). */
15export type BrowserBinding = Binding.Binding<
16 Browser["key"],
17 BrowserClient,
18 Browser
19>;
20
21/**
22 * The layer that provides the Effect-native interface for the Cloudflare
23 * Workers Browser Rendering binding.
24 *
25 * Provide it on the Worker effect (`Effect.provide(Cloudflare.Workers.BrowserBinding)`)
26 * so that yielding a {@link Browser} binding attaches the native `browser`
27 * binding to the surrounding Worker at deploy time and, at runtime, resolves to
28 * the Effect-native {@link BrowserClient}.
29 */
30export const BrowserBinding = makeBindingLayer<
31 Browser,
32 cf.BrowserRun,
33 BrowserClient
34>(Browser, (raw): BrowserClient => {
35 const respond = (
36 action: string,
37 options: unknown,
38 ): Effect.Effect<BrowserResponse, BrowserError, RuntimeContext> =>
39 raw.pipe(
40 Effect.flatMap((binding) =>
41 tryPromise(() => binding.quickAction(action as any, options as any)),
42 ),
43 Effect.flatMap((response) =>
44 response.ok ? Effect.succeed(response) : failResponse(action, response),
45 ),
46 );
47
48 const jsonAction = <T>(
49 action: string,
50 options: unknown,
51 ): Effect.Effect<T, BrowserError, RuntimeContext> =>
52 respond(action, options).pipe(
53 Effect.flatMap((response) =>
54 tryPromise(() => response.json() as Promise<T>),
55 ),
56 );
57
58 const streamAction = (
59 action: string,
60 options: unknown,
61 ): Stream.Stream<Uint8Array, BrowserError, RuntimeContext> =>
62 respond(action, options).pipe(
63 Effect.map((response) =>
64 Stream.fromReadableStream({
65 evaluate: () =>
66 response.body as any as ReadableStream<Uint8Array<ArrayBufferLike>>,
67 onError: (cause) =>
68 new BrowserError({
69 message: `Browser Rendering '${action}' stream failed`,
70 cause,
71 }),
72 }),
73 ),
74 Stream.unwrap,
75 );
76
77 const quickAction = ((action: string, options: unknown) =>
78 BINARY_ACTIONS.has(action)
79 ? streamAction(action, options)
80 : jsonAction(action, options)) as BrowserClient["quickAction"];
81
82 return {
83 raw,
84 fetch: (...args) =>
85 raw.pipe(
86 Effect.flatMap((binding) => tryPromise(() => binding.fetch(...args))),
87 ),
88 quickAction,
89 screenshot: (options) => streamAction("screenshot", options),
90 pdf: (options) => streamAction("pdf", options),
91 content: (options) => jsonAction("content", options),
92 scrape: (options) => jsonAction("scrape", options),
93 links: (options) => jsonAction("links", options),
94 snapshot: (options) => jsonAction("snapshot", options),
95 json: (options) => jsonAction("json", options),
96 markdown: (options) => jsonAction("markdown", options),
97 } satisfies BrowserClient;
98});
99
100/** Actions whose successful response is raw binary rather than JSON. */
101const BINARY_ACTIONS = new Set(["screenshot", "pdf"]);
102
103/** Build a {@link BrowserError} from a non-success Browser Run response. */
104const failResponse = (
105 action: string,
106 response: BrowserResponse,
107): Effect.Effect<never, BrowserError> =>
108 tryPromise(() => response.text()).pipe(
109 Effect.flatMap((body) => {
110 let cause: unknown = body;
111 try {
112 cause = JSON.parse(body);
113 } catch {
114 // keep the raw text as the cause
115 }
116 const message =
117 (cause as cf.BrowserRunErrorResponse | undefined)?.errors?.[0]
118 ?.message ??
119 `Browser Rendering '${action}' failed with status ${response.status}`;
120 return Effect.fail(new BrowserError({ message, cause }));
121 }),
122 );
123
124const tryPromise = <T>(fn: () => Promise<T>): Effect.Effect<T, BrowserError> =>
125 Effect.tryPromise({
126 try: fn,
127 catch: (error) =>
128 new BrowserError({
129 message:
130 error instanceof Error
131 ? error.message
132 : "Unknown Browser Rendering error",
133 cause: error,
134 }),
135 });