1import * as Cloudflare from "@/Cloudflare";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { HttpServerRequest } from "effect/http/HttpServerRequest";
5import * as HttpServerResponse from "effect/http/HttpServerResponse";
6
7const TARGET_URL = "https://example.com";
8
9const byteLength = <E, R>(stream: Stream.Stream<Uint8Array, E, R>) =>
10 stream.pipe(
11 Stream.runCollect,
12 Effect.map((chunks) =>
13 Array.from(chunks).reduce((total, chunk) => total + chunk.length, 0),
14 ),
15 );
16
17export default class BrowserEffectWorker extends Cloudflare.Worker<BrowserEffectWorker>()(
18 "BrowserEffectWorker",
19 {
20 main: import.meta.url,
21 },
22 Effect.gen(function* () {
23 const browser = yield* Cloudflare.Browser("BROWSER");
24
25 return {
26 fetch: Effect.gen(function* () {
27 const request = yield* HttpServerRequest;
28 const path = request.url.split("?")[0];
29
30 switch (path) {
31 // JSON quick actions resolve to their parsed payload directly.
32 case "/content": {
33 const content = yield* browser
34 .content({ url: TARGET_URL })
35 .pipe(Effect.orDie);
36 return yield* HttpServerResponse.json({
37 title: content.meta.title,
38 contentLength: content.result.length,
39 });
40 }
41 case "/markdown": {
42 const markdown = yield* browser
43 .markdown({ url: TARGET_URL })
44 .pipe(Effect.orDie);
45 return yield* HttpServerResponse.json({
46 markdownLength: markdown.result.length,
47 });
48 }
49 case "/links": {
50 const links = yield* browser
51 .links({ url: TARGET_URL })
52 .pipe(Effect.orDie);
53 return yield* HttpServerResponse.json({
54 linkCount: links.result.length,
55 });
56 }
57 case "/scrape": {
58 const scrape = yield* browser
59 // example.com has no heading; its only link reads "Learn more".
60 .scrape({ url: TARGET_URL, elements: [{ selector: "a" }] })
61 .pipe(Effect.orDie);
62 return yield* HttpServerResponse.json({
63 text: scrape.result[0]?.results[0]?.text ?? null,
64 });
65 }
66 case "/snapshot": {
67 const snapshot = yield* browser
68 .snapshot({ url: TARGET_URL })
69 .pipe(Effect.orDie);
70 return yield* HttpServerResponse.json({
71 title: snapshot.meta.title,
72 screenshotLength: snapshot.result.screenshot?.length ?? 0,
73 });
74 }
75 case "/json": {
76 const extracted = yield* browser
77 .json({
78 url: TARGET_URL,
79 prompt: "Extract the page heading as { heading: string }",
80 })
81 .pipe(Effect.orDie);
82 return yield* HttpServerResponse.json({
83 success: extracted.success,
84 });
85 }
86 // Binary actions stream bytes; drain the stream to count them.
87 case "/screenshot": {
88 const bytes = yield* byteLength(
89 browser.screenshot({ url: TARGET_URL }),
90 ).pipe(Effect.orDie);
91 return yield* HttpServerResponse.json({ bytes });
92 }
93 case "/pdf": {
94 const bytes = yield* byteLength(
95 browser.pdf({ url: TARGET_URL }),
96 ).pipe(Effect.orDie);
97 return yield* HttpServerResponse.json({ bytes });
98 }
99 // Generic `quickAction` passthrough.
100 case "/quickAction": {
101 const snapshot = yield* browser
102 .quickAction("snapshot", { url: TARGET_URL })
103 .pipe(Effect.orDie);
104 return yield* HttpServerResponse.json({
105 title: snapshot.meta.title,
106 });
107 }
108 // `raw` exposes the underlying `cf.BrowserRun` binding (puppeteer, etc.).
109 case "/raw": {
110 const binding = yield* browser.raw;
111 const res = yield* Effect.tryPromise(() =>
112 binding.quickAction("content", { url: TARGET_URL }),
113 ).pipe(Effect.orDie);
114 const body = yield* Effect.promise(
115 () =>
116 res.json() as Promise<Cloudflare.Workers.BrowserContentResult>,
117 );
118 return yield* HttpServerResponse.json({ title: body.meta.title });
119 }
120 default:
121 return HttpServerResponse.text("ok");
122 }
123 }),
124 };
125 }).pipe(Effect.provide(Cloudflare.Workers.BrowserBinding)),
126) {}