1import { createFileRoute } from "@tanstack/react-router";
2import * as Cloudflare from "alchemy/Cloudflare/Bridge";
3import * as Effect from "effect/Effect";
4import * as HttpClientRequest from "effect/http/HttpClientRequest";
5import * as HttpServerResponse from "effect/http/HttpServerResponse";
6import type Backend from "../backend.ts";
7import { env } from "../env.ts";
8
9const VIAS = ["binding", "fetch", "rpc", "http-client"] as const;
10type Via = (typeof VIAS)[number];
11
12const parseRequest = (request: Request): { via: Via; key: string | null } => {
13 const url = new URL(request.url);
14 const raw = url.searchParams.get("via") ?? "binding";
15 const via = (VIAS as readonly string[]).includes(raw)
16 ? (raw as Via)
17 : "binding";
18 return { via, key: url.searchParams.get("key") };
19};
20
21// Surface the actual error in BOTH the response body and the worker logs —
22// otherwise TanStack Start's outer boundary swallows it and you only ever
23// see "HTTPError" in `bun alchemy logs`.
24const trace = async (label: string, fn: () => Promise<Response>) => {
25 try {
26 return await fn();
27 } catch (err) {
28 const message =
29 err instanceof Error ? (err.stack ?? err.message) : String(err);
30 console.error(`[api.hello] ${label} failed:`, message);
31 return new Response(`${label} failed: ${message}`, { status: 500 });
32 }
33};
34
35export const Route = createFileRoute("/api/hello")({
36 server: {
37 handlers: {
38 // GET /api/hello?key=<key>&via=binding|fetch|rpc
39 GET: async ({ request }) => {
40 const { via, key } = parseRequest(request);
41 if (!key) {
42 return new Response("Missing 'key' query parameter", { status: 400 });
43 }
44
45 switch (via) {
46 // option 1 — use the async binding directly
47 case "binding":
48 return trace("GET option 1 (env.BUCKET.get)", async () => {
49 const object = await env.BUCKET.get(key);
50 if (!object) return new Response("Not found", { status: 404 });
51 return new Response(object.body);
52 });
53
54 // option 2 — bind to your effect worker and call fetch
55 case "fetch":
56 return trace("GET option 2 (env.BACKEND.fetch)", async () => {
57 const res = await env.BACKEND.fetch(
58 `https://backend/?key=${encodeURIComponent(key)}`,
59 );
60 return new Response(res.body, {
61 status: res.status,
62 headers: res.headers,
63 });
64 });
65
66 // option 3 — bind to your effect worker and call rpc method
67 case "rpc":
68 return trace("GET option 3 (backend.hello rpc)", async () => {
69 // Wrap the raw wire-shape binding into a Promise<T> view that
70 // throws on Effect.fail and unwraps stream envelopes.
71 const backend = Cloudflare.toRpcAsync<Backend>(env.BACKEND);
72 const value = await backend.hello(key);
73 if (value === null)
74 return new Response("Not found", { status: 404 });
75 return new Response(value);
76 });
77
78 // option 4 — bind to your effect worker and call http client
79 case "http-client":
80 return trace("GET option 4 (env.BACKEND.httpClient)", async () => {
81 const client = Cloudflare.toHttpClient(
82 Cloudflare.fromCloudflareFetcher(env.BACKEND),
83 );
84 const res = await client
85 .get(`https://backend/?key=${encodeURIComponent(key)}`)
86 .pipe(Effect.runPromise);
87 return HttpServerResponse.toWeb(
88 HttpServerResponse.fromClientResponse(res),
89 );
90 });
91 }
92 },
93
94 // PUT /api/hello?key=<key>&via=binding|fetch
95 // (option 3 is GET-only — `hello` is a read RPC for demonstration.)
96 PUT: async ({ request }) => {
97 const { via, key } = parseRequest(request);
98 if (!key) {
99 return new Response("Missing 'key' query parameter", { status: 400 });
100 }
101 if (!request.body) {
102 return new Response("Missing request body", { status: 400 });
103 }
104
105 switch (via) {
106 // option 1 — use the async binding directly
107 case "binding":
108 return trace("PUT option 1 (env.BUCKET.put)", async () => {
109 await env.BUCKET.put(key, request.body, {
110 httpMetadata: {
111 contentType:
112 request.headers.get("content-type") ??
113 "application/octet-stream",
114 },
115 });
116 return new Response(null, { status: 204 });
117 });
118
119 // option 2 — bind to your effect worker and call fetch
120 case "fetch":
121 return trace("PUT option 2 (env.BACKEND.fetch)", async () => {
122 const res = await env.BACKEND.fetch(
123 `https://backend/?key=${encodeURIComponent(key)}`,
124 {
125 method: "PUT",
126 body: request.body,
127 headers: request.headers,
128 },
129 );
130 return new Response(res.body, {
131 status: res.status,
132 headers: res.headers,
133 });
134 });
135
136 // option 3 — RPC `hello` is read-only
137 case "rpc":
138 return new Response("PUT is not supported via=rpc", {
139 status: 400,
140 });
141
142 // option 4 — bind to your effect worker and call http client
143 case "http-client":
144 return trace("PUT option 4 (env.BACKEND.httpClient)", async () => {
145 const client = Cloudflare.toHttpClient(
146 Cloudflare.fromCloudflareFetcher(env.BACKEND),
147 );
148 const res = await client
149 .execute(HttpClientRequest.fromWeb(request))
150 .pipe(Effect.runPromise);
151 return HttpServerResponse.toWeb(
152 HttpServerResponse.fromClientResponse(res),
153 { withoutBody: res.status === 204 },
154 );
155 });
156 }
157 },
158 },
159 },
160});