import * as browser from "@distilled.cloud/cloudflare/browser-rendering";
import * as Effect from "effect/Effect";
import * as Stream from "effect/Stream";
import type * as HttpClient from "effect/unstable/http/HttpClient";
import type { RuntimeContext } from "../../RuntimeContext.ts";
import type { Credentials } from "../Credentials.ts";
import {
type BrowserClient,
type BrowserContentResult,
BrowserError,
type BrowserJsonResult,
type BrowserLinksResult,
type BrowserMarkdownResult,
type BrowserScrapeResult,
type BrowserSnapshotResult,
} from "./Browser.ts";
// Shared HTTP scaffolding for the Browser Rendering binding. NOT re-exported
// from `index.ts` — only the contract and the impl layers are public. A future
// token-scoped `BrowserHttp` layer reuses this builder with an auth minted from
// an `AccountApiToken`; `BrowserLocal` builds the auth from the ambient
// current-credentials context.
/**
* Injectable auth shared by the Local (current-credentials) impl and a future
* Http (scoped-token) impl. `authorize` discharges the
* `Credentials | HttpClient` requirement of a distilled op; `accountId` is the
* Cloudflare account the ops run against.
*/
export interface BrowserAuth {
authorize: (
eff: Effect.Effect,
) => Effect.Effect;
accountId: string;
}
/** A byte stream produced by a binary {@link BrowserClient} action. */
type BrowserByteStream = Stream.Stream<
Uint8Array,
BrowserError,
RuntimeContext
>;
/**
* Build a {@link BrowserClient} over the Browser Rendering REST data-plane
* (`/accounts/{id}/browser-rendering/*`).
*
* The following client members `Effect.die` (or fail the stream) because they
* have no Cloudflare REST equivalent — use a native `BrowserBinding` inside a
* deployed Worker for them:
* - `raw` / `fetch` — the raw `BrowserRun` transport used by
* `@cloudflare/puppeteer` is a Worker-runtime object, not an HTTP call.
* - `screenshot` / `pdf` — the binary endpoints stream image/PDF bytes, which
* the distilled REST codec models as JSON status, not a byte stream.
*
* Because the REST data-plane only returns the action `result` (not the
* runtime binding's `meta` envelope), `content`/`snapshot` populate `meta`
* with a best-effort placeholder.
*/
export const makeHttpBrowserClient = (auth: BrowserAuth): BrowserClient => {
// Run a distilled Browser Rendering op with the injected auth and surface
// transport/API failures as {@link BrowserError} (matching the native
// binding's declared error channel).
const run = (
eff: Effect.Effect,
): Effect.Effect =>
auth.authorize(eff).pipe(
Effect.mapError(
(cause) =>
new BrowserError({
message: "Browser Rendering request failed",
cause,
}),
),
);
// The cf option types (`url`/`html` + puppeteer options) are structurally the
// REST request body; add the account id and let distilled's encoder drop any
// fields it doesn't model.
const req = (options: unknown) =>
({ accountId: auth.accountId, ...(options as object) }) as never;
const content = (options: unknown) =>
run(browser.createContent(req(options))).pipe(
Effect.map((result): BrowserContentResult => ({
success: true,
result,
meta: { status: 200, title: "" },
})),
);
const markdown = (options: unknown) =>
run(browser.createMarkdown(req(options))).pipe(
Effect.map((result): BrowserMarkdownResult => ({
success: true,
result,
})),
);
const links = (options: unknown) =>
run(browser.createLink(req(options))).pipe(
Effect.map((result): BrowserLinksResult => ({
success: true,
result: [...result],
})),
);
const json = (options: unknown) =>
run(browser.createJson(req(options))).pipe(
Effect.map((result): BrowserJsonResult => ({ success: true, result })),
);
const scrape = (options: unknown) =>
run(browser.createScrape(req(options))).pipe(
Effect.map((result): BrowserScrapeResult => ({
success: true,
result: result.map((item) => ({
selector: item.selector,
// distilled models per-selector `results` as a single object; the
// native shape is an array of matched elements.
results: (Array.isArray(item.results)
? item.results
: [
item.results,
]) as BrowserScrapeResult["result"][number]["results"],
})),
})),
);
const snapshot = (options: unknown) =>
run(browser.createSnapshot(req(options))).pipe(
Effect.map((result): BrowserSnapshotResult => ({
success: true,
result: {
content: result.content ?? "",
screenshot: result.screenshot ?? "",
},
meta: { status: 200, title: "" },
})),
);
// Binary actions have no REST byte-stream equivalent through the distilled
// codec — fail the stream with a defect explaining the native-binding path.
const binaryUnsupported = (action: string): BrowserByteStream =>
Stream.fromEffect(
Effect.die(
new Error(
`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).`,
),
),
) as BrowserByteStream;
const quickAction = ((action: string, options: unknown) => {
switch (action) {
case "content":
return content(options);
case "markdown":
return markdown(options);
case "links":
return links(options);
case "json":
return json(options);
case "scrape":
return scrape(options);
case "snapshot":
return snapshot(options);
default:
return binaryUnsupported(action);
}
}) as BrowserClient["quickAction"];
return {
raw: Effect.die(
new Error(
"Browser over HTTP: `raw` (the native BrowserRun binding) is only available inside a deployed Worker — use BrowserBinding.",
),
),
fetch: () =>
Effect.die(
new Error(
"Browser over HTTP: `fetch` proxies the raw Browser Run transport used by @cloudflare/puppeteer and is only available inside a Worker via BrowserBinding.",
),
),
quickAction,
screenshot: () => binaryUnsupported("screenshot"),
pdf: () => binaryUnsupported("pdf"),
content,
scrape,
links,
snapshot,
json,
markdown,
} satisfies BrowserClient;
};