1import * as Cloudflare from "alchemy/Cloudflare";
2import * as Test from "alchemy/Test/Bun";
3import { expect } from "bun:test";
4import * as Console from "effect/Console";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import Stack from "../alchemy.run.ts";
11
12// Fresh `workers.dev` URLs transiently 404 while the route propagates.
13// `HttpClient.execute`/`get` resolve successfully on that 404, so a plain
14// `Effect.retry` never fires — these helpers fail on the cold-start window and
15// retry until the real response (which may be 200/204/400) comes back.
16const { executeWhenReady, getWhenReady } = Test;
17
18class AssetNotReady extends Data.TaggedError("AssetNotReady")<{
19 body: string;
20}> {}
21
22// While the static-asset manifest is still propagating, requests can serve a
23// stale or placeholder body with a 200 — the status alone can't distinguish
24// "not yet" from "served", so retry until the body matches.
25const getBodyWhenReady = (url: string, expected: string) =>
26 Effect.gen(function* () {
27 const res = yield* getWhenReady(url);
28 expect(res.status).toBe(200);
29 const body = yield* res.text;
30 if (!body.includes(expected)) {
31 return yield* Effect.fail(new AssetNotReady({ body }));
32 }
33 return body;
34 }).pipe(
35 Effect.retry({
36 while: (error) => error instanceof AssetNotReady,
37 schedule: Schedule.max([
38 Schedule.min([
39 Schedule.exponential("500 millis"),
40 Schedule.spaced("3 seconds"),
41 ]),
42 Schedule.recurs(20),
43 ]),
44 }),
45 );
46
47const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
48 providers: Cloudflare.providers(),
49 state: Cloudflare.state(),
50});
51
52const stack = beforeAll(deploy(Stack).pipe(Effect.tap(Console.log)), {
53 timeout: 240_000,
54});
55afterAll(
56 Effect.gen(function* () {
57 if (!process.env.NO_DESTROY) {
58 yield* destroy(Stack);
59 }
60 }),
61 { timeout: 180_000 },
62);
63
64const route = (url: string, params: Record<string, string>) =>
65 `${url}/api/hello?${new URLSearchParams(params).toString()}`;
66
67// Stable per-option keys so re-runs (e.g. NO_DESTROY=1) overwrite cleanly
68// instead of leaving stale objects behind.
69const KEYS = {
70 binding: "integ:via-binding",
71 fetch: "integ:via-fetch",
72 rpc: "integ:via-rpc",
73 httpClient: "integ:via-http-client",
74};
75
76test(
77 "deploys and exposes a url",
78 Effect.gen(function* () {
79 const { websiteUrl } = yield* stack;
80 expect(websiteUrl).toBeString();
81 }),
82 { timeout: 180_000 },
83);
84
85test(
86 "serves the server-rendered home page",
87 Effect.gen(function* () {
88 const { websiteUrl } = yield* stack;
89 const base = websiteUrl.replace(/\/+$/, "");
90 // The `GREETING` env value from alchemy.run.ts, read through the
91 // `cloudflare:workers` env proxy in the server function — proves the
92 // Worker rendered it at request time.
93 const html = yield* getBodyWhenReady(
94 base,
95 "Hello from TanStack Start on Cloudflare!",
96 );
97 // The Card component rendered under the heading.
98 expect(html).toContain("Styled with Tailwind CSS");
99 expect(html).toContain(
100 "This card is a React component styled with Tailwind utilities.",
101 );
102 }),
103 { timeout: 180_000 },
104);
105
106test(
107 "compiles tailwind from vite.config.ts",
108 Effect.gen(function* () {
109 const { websiteUrl } = yield* stack;
110 const base = websiteUrl.replace(/\/+$/, "");
111 const res = yield* getWhenReady(base);
112 expect(res.status).toBe(200);
113 const html = yield* res.text;
114 // The SSR'd markup uses Tailwind utilities...
115 expect(html).toContain("text-3xl");
116 // ...and links the stylesheet Vite emitted via the project-owned
117 // vite.config.ts (the @tailwindcss/vite plugin), proving Alchemy loaded
118 // the config file natively instead of the programmatic fallback.
119 const match = html.match(/<link[^>]*href="([^"]+\.css[^"]*)"/);
120 expect(match).not.toBeNull();
121 const href = match![1]!;
122 const cssUrl = href.startsWith("http") ? href : `${base}${href}`;
123 const css = yield* getBodyWhenReady(cssUrl, ".text-3xl");
124 expect(css).toContain(".text-3xl");
125 expect(css).toContain(".font-bold");
126 }),
127 { timeout: 180_000 },
128);
129
130test(
131 "option 1 — direct R2 binding round-trips through PUT and GET",
132 Effect.gen(function* () {
133 const { websiteUrl } = yield* stack;
134 const client = yield* HttpClient.HttpClient;
135 const key = KEYS.binding;
136
137 const put = yield* executeWhenReady(
138 HttpClientRequest.put(route(websiteUrl, { key, via: "binding" })).pipe(
139 HttpClientRequest.bodyText("hello-binding", "text/plain"),
140 ),
141 );
142 expect(put.status).toBe(204);
143
144 const get = yield* client.get(route(websiteUrl, { key, via: "binding" }));
145 expect(get.status).toBe(200);
146 expect(yield* get.text).toBe("hello-binding");
147 }),
148 { timeout: 180_000 },
149);
150
151test(
152 "option 2 — service-binding fetch into the Backend worker",
153 Effect.gen(function* () {
154 const { websiteUrl } = yield* stack;
155 const client = yield* HttpClient.HttpClient;
156 const key = KEYS.fetch;
157
158 // Write through option 2's PUT path (Backend's fetch handler stores it
159 // in R2), then read it back through option 2's GET (also Backend.fetch).
160 const put = yield* executeWhenReady(
161 HttpClientRequest.put(route(websiteUrl, { key, via: "fetch" })).pipe(
162 HttpClientRequest.bodyText("hello-fetch", "text/plain"),
163 ),
164 );
165 expect(put.status).toBe(204);
166
167 const get = yield* client.get(route(websiteUrl, { key, via: "fetch" }));
168 expect(get.status).toBe(200);
169 expect(yield* get.text).toBe("hello-fetch");
170 }),
171 { timeout: 180_000 },
172);
173
174test(
175 "option 3 — service-binding RPC method via toPromiseApi",
176 Effect.gen(function* () {
177 const { websiteUrl } = yield* stack;
178 const client = yield* HttpClient.HttpClient;
179 const key = KEYS.rpc;
180
181 // Seed the bucket via option 1 (direct binding) so the RPC `hello`
182 // method has something to read.
183 const seed = yield* executeWhenReady(
184 HttpClientRequest.put(route(websiteUrl, { key, via: "binding" })).pipe(
185 HttpClientRequest.bodyText("hello-rpc", "text/plain"),
186 ),
187 );
188 expect(seed.status).toBe(204);
189
190 // RPC GET reads through Backend.hello — exercises toPromiseApi.
191 const get = yield* client.get(route(websiteUrl, { key, via: "rpc" }));
192 expect(get.status).toBe(200);
193 expect(yield* get.text).toBe("hello-rpc");
194 }),
195 { timeout: 180_000 },
196);
197
198test(
199 "option 4 — service-binding HTTP client",
200 Effect.gen(function* () {
201 const { websiteUrl } = yield* stack;
202 const client = yield* HttpClient.HttpClient;
203 const key = KEYS.httpClient;
204
205 // Seed the bucket via option 1 (direct binding) so the RPC `hello`
206 // method has something to read.
207 const seed = yield* executeWhenReady(
208 HttpClientRequest.put(
209 route(websiteUrl, { key, via: "http-client" }),
210 ).pipe(HttpClientRequest.bodyText("hello-http-client", "text/plain")),
211 );
212 expect(seed.status).toBe(204);
213
214 // HTTP client GET reads through Backend.hello — exercises toPromiseApi.
215 const get = yield* client.get(
216 route(websiteUrl, { key, via: "http-client" }),
217 );
218 expect(get.status).toBe(200);
219 expect(yield* get.text).toBe("hello-http-client");
220 }),
221 { timeout: 180_000 },
222);
223
224test(
225 "missing `key` returns 400",
226 Effect.gen(function* () {
227 const { websiteUrl } = yield* stack;
228
229 // `400` is the real answer; `getWhenReady` only retries the propagation
230 // `404`/`5xx` window, so it returns the `400` as soon as the route is live.
231 const res = yield* getWhenReady(route(websiteUrl, { via: "binding" }));
232 expect(res.status).toBe(400);
233 }),
234);
235
236test(
237 "RPC for a non-existent key returns 404",
238 Effect.gen(function* () {
239 const { websiteUrl } = yield* stack;
240 const client = yield* HttpClient.HttpClient;
241
242 const res = yield* client.get(
243 route(websiteUrl, { key: "integ:does-not-exist", via: "rpc" }),
244 );
245 expect(res.status).toBe(404);
246 }),
247);
248
249test(
250 "PUT via=rpc returns 400 (RPC `hello` is read-only)",
251 Effect.gen(function* () {
252 const { websiteUrl } = yield* stack;
253
254 const res = yield* executeWhenReady(
255 HttpClientRequest.put(
256 route(websiteUrl, { key: "integ:via-options", via: "rpc" }),
257 ).pipe(HttpClientRequest.bodyText("nope")),
258 );
259 expect(res.status).toBe(400);
260 }),
261);