1import * as browser from "@distilled.cloud/cloudflare/browser-rendering";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import type * as HttpClient from "effect/http/HttpClient";
5import type { RuntimeContext } from "../../RuntimeContext.ts";
6import type { Credentials } from "../Credentials.ts";
7import {
8 type BrowserClient,
9 type BrowserContentResult,
10 BrowserError,
11 type BrowserJsonResult,
12 type BrowserLinksResult,
13 type BrowserMarkdownResult,
14 type BrowserScrapeResult,
15 type BrowserSnapshotResult,
16} from "./Browser.ts";
17
18// Shared HTTP scaffolding for the Browser Rendering binding. NOT re-exported
19// from `index.ts` — only the contract and the impl layers are public. A future
20// token-scoped `BrowserHttp` layer reuses this builder with an auth minted from
21// an `AccountApiToken`; `BrowserLocal` builds the auth from the ambient
22// current-credentials context.
23
24/**
25 * Injectable auth shared by the Local (current-credentials) impl and a future
26 * Http (scoped-token) impl. `authorize` discharges the
27 * `Credentials | HttpClient` requirement of a distilled op; `accountId` is the
28 * Cloudflare account the ops run against.
29 */
30export interface BrowserAuth {
31 authorize: <A, E>(
32 eff: Effect.Effect<A, E, Credentials | HttpClient.HttpClient>,
33 ) => Effect.Effect<A, E>;
34 accountId: string;
35}
36
37/** A byte stream produced by a binary {@link BrowserClient} action. */
38type BrowserByteStream = Stream.Stream<
39 Uint8Array,
40 BrowserError,
41 RuntimeContext
42>;
43
44/**
45 * Build a {@link BrowserClient} over the Browser Rendering REST data-plane
46 * (`/accounts/{id}/browser-rendering/*`).
47 *
48 * The following client members `Effect.die` (or fail the stream) because they
49 * have no Cloudflare REST equivalent — use a native `BrowserBinding` inside a
50 * deployed Worker for them:
51 * - `raw` / `fetch` — the raw `BrowserRun` transport used by
52 * `@cloudflare/puppeteer` is a Worker-runtime object, not an HTTP call.
53 * - `screenshot` / `pdf` — the binary endpoints stream image/PDF bytes, which
54 * the distilled REST codec models as JSON status, not a byte stream.
55 *
56 * Because the REST data-plane only returns the action `result` (not the
57 * runtime binding's `meta` envelope), JSON actions populate `meta` with a
58 * best-effort placeholder.
59 */
60export const makeHttpBrowserClient = (auth: BrowserAuth): BrowserClient => {
61 // Run a distilled Browser Rendering op with the injected auth and surface
62 // transport/API failures as {@link BrowserError} (matching the native
63 // binding's declared error channel).
64 const run = <A, E>(
65 eff: Effect.Effect<A, E, Credentials | HttpClient.HttpClient>,
66 ): Effect.Effect<A, BrowserError, RuntimeContext> =>
67 auth.authorize(eff).pipe(
68 Effect.mapError(
69 (cause) =>
70 new BrowserError({
71 message: "Browser Rendering request failed",
72 cause,
73 }),
74 ),
75 );
76
77 // The cf option types (`url`/`html` + puppeteer options) are structurally the
78 // REST request body; add the account id and let distilled's encoder drop any
79 // fields it doesn't model.
80 const req = (options: unknown) =>
81 ({ accountId: auth.accountId, ...(options as object) }) as never;
82
83 const content = (options: unknown) =>
84 run(browser.createContent(req(options))).pipe(
85 Effect.map((result): BrowserContentResult => ({
86 success: true,
87 result,
88 meta: { status: 200, title: "" },
89 })),
90 );
91
92 const markdown = (options: unknown) =>
93 run(browser.createMarkdown(req(options))).pipe(
94 Effect.map((result): BrowserMarkdownResult => ({
95 success: true,
96 result,
97 meta: { status: 200, title: "" },
98 })),
99 );
100
101 const links = (options: unknown) =>
102 run(browser.createLink(req(options))).pipe(
103 Effect.map((result): BrowserLinksResult => ({
104 success: true,
105 result: [...result],
106 meta: { status: 200, title: "" },
107 })),
108 );
109
110 const json = (options: unknown) =>
111 run(browser.createJson(req(options))).pipe(
112 Effect.map((result): BrowserJsonResult => ({
113 success: true,
114 result,
115 meta: { status: 200, title: "" },
116 })),
117 );
118
119 const scrape = (options: unknown) =>
120 run(browser.createScrape(req(options))).pipe(
121 Effect.map((result): BrowserScrapeResult => ({
122 success: true,
123 result: result.map((item) => ({
124 selector: item.selector,
125 // distilled models per-selector `results` as a single object; the
126 // native shape is an array of matched elements.
127 results: (Array.isArray(item.results)
128 ? item.results
129 : [
130 item.results,
131 ]) as BrowserScrapeResult["result"][number]["results"],
132 })),
133 meta: { status: 200, title: "" },
134 })),
135 );
136
137 const snapshot = (options: unknown) =>
138 run(browser.createSnapshot(req(options))).pipe(
139 Effect.map((result): BrowserSnapshotResult => ({
140 success: true,
141 result: {
142 content: result.content ?? "",
143 screenshot: result.screenshot ?? "",
144 },
145 meta: { status: 200, title: "" },
146 })),
147 );
148
149 // Binary actions have no REST byte-stream equivalent through the distilled
150 // codec — fail the stream with a defect explaining the native-binding path.
151 const binaryUnsupported = (action: string): BrowserByteStream =>
152 Stream.fromEffect(
153 Effect.die(
154 new Error(
155 `Browser over HTTP: '${action}' returns binary data and is only available inside a Worker via the native BrowserBinding; the HTTP client supports the JSON quick actions (content/markdown/scrape/links/snapshot/json).`,
156 ),
157 ),
158 ) as BrowserByteStream;
159
160 const quickAction = ((action: string, options: unknown) => {
161 switch (action) {
162 case "content":
163 return content(options);
164 case "markdown":
165 return markdown(options);
166 case "links":
167 return links(options);
168 case "json":
169 return json(options);
170 case "scrape":
171 return scrape(options);
172 case "snapshot":
173 return snapshot(options);
174 default:
175 return binaryUnsupported(action);
176 }
177 }) as BrowserClient["quickAction"];
178
179 return {
180 raw: Effect.die(
181 new Error(
182 "Browser over HTTP: `raw` (the native BrowserRun binding) is only available inside a deployed Worker — use BrowserBinding.",
183 ),
184 ),
185 fetch: () =>
186 Effect.die(
187 new Error(
188 "Browser over HTTP: `fetch` proxies the raw Browser Run transport used by @cloudflare/puppeteer and is only available inside a Worker via BrowserBinding.",
189 ),
190 ),
191 quickAction,
192 screenshot: () => binaryUnsupported("screenshot"),
193 pdf: () => binaryUnsupported("pdf"),
194 content,
195 scrape,
196 links,
197 snapshot,
198 json,
199 markdown,
200 } satisfies BrowserClient;
201};