1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import * as Test from "@/Test/Alchemy";
4import * as r2 from "@distilled.cloud/cloudflare/r2";
5import { describe, expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import * as pathe from "pathe";
13import { cloneFixture } from "../Utils/Fixture.ts";
14import { expectUrlContains } from "../Utils/Http.ts";
15import {
16 expectWorkerExists,
17 waitForWorkerToBeDeleted,
18} from "../Utils/Worker.ts";
19
20const { test } = Test.make({ providers: Cloudflare.providers() });
21
22const logLevel = Effect.provideService(
23 MinimumLogLevel,
24 process.env.DEBUG ? "Debug" : "Info",
25);
26
27const fixtureDir = pathe.resolve(
28 import.meta.dirname,
29 "tanstack-dev-bindings-fixture",
30);
31
32// Vite project roots must stay reachable via a sane relative path from the
33// process cwd (see Vite.test.ts) — clone under the package's own `.tmp/`.
34const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
35
36describe.concurrent(
37 "TanStack Start",
38 {
39 tags: [
40 "provider:cloudflare",
41 "provider:cloudflare:r2",
42 "provider:cloudflare:website",
43 "provider:cloudflare:worker",
44 "live",
45 ],
46 },
47 () => {
48 /**
49 * TanStack Start deploys through `Cloudflare.Website.Vite` (per the Vite
50 * resource's TanStack example) — the `tanstackStart()` plugin in the
51 * fixture's own `vite.config.ts` composes with the injected Cloudflare
52 * plugin. This is the live-deploy counterpart to the dev-mode coverage in
53 * Vite.test.ts: SSR HTML on a raw fetch, a server route answering, the
54 * client asset serving, and an R2 binding round-trip.
55 */
56 test.provider(
57 "TanStack Start: live deploy serves SSR, server routes, assets, and R2 bindings",
58 (stack) =>
59 Effect.gen(function* () {
60 const { accountId } = yield* yield* CloudflareEnvironment;
61
62 yield* stack.destroy();
63
64 const rootDir = yield* cloneFixture(fixtureDir, {
65 prefix: "alchemy-tanstack-live-",
66 tempRoot,
67 entries: [
68 "alchemy.run.ts",
69 "package.json",
70 "tsconfig.json",
71 "vite.config.ts",
72 "src",
73 ],
74 });
75 const memoInclude = [
76 "src/**",
77 "package.json",
78 "tsconfig.json",
79 "vite.config.ts",
80 "alchemy.run.ts",
81 ];
82
83 const marker = "tanstack-live-marker";
84
85 const { site, bucket } = yield* stack.deploy(
86 Effect.gen(function* () {
87 const bucket = yield* Cloudflare.R2.Bucket("TanStackLiveBucket", {
88 forceDestroy: true,
89 });
90 const site = yield* Cloudflare.Website.Vite("TanStackStartLive", {
91 rootDir,
92 workersDev: true,
93 compatibility: {
94 date: "2024-09-23",
95 flags: ["nodejs_compat"],
96 },
97 // No `assets` config (mirroring the Vite resource's TanStack
98 // example): client assets serve asset-first from the asset
99 // layer, everything else (SSR routes, /api/*) falls through
100 // to the TanStack server handler. `runWorkerFirst: true`
101 // would route `/assets/*` into the worker, which 404s them.
102 memo: { include: memoInclude },
103 env: {
104 BUCKET: bucket,
105 DEV_MARKER: marker,
106 },
107 });
108 return { site, bucket };
109 }),
110 );
111
112 expect(site.url).toBeDefined();
113 yield* expectWorkerExists(site.workerName, accountId);
114
115 // ── SSR: the index route's component renders server-side ─────────
116 // `hmr-marker-fixture` is the constant baked into the fixture's
117 // index route — a raw fetch (no client JS) must carry it.
118 yield* expectUrlContains(`${site.url!}/`, "hmr-marker-fixture", {
119 timeout: "120 seconds",
120 label: "tanstack ssr home",
121 });
122
123 // ── Server route + R2 binding round-trip ─────────────────────────
124 const key = "tanstack-live-key.txt";
125 const r2Url = `${site.url!}/api/r2?key=${encodeURIComponent(key)}`;
126
127 const put = yield* putTextJsonReady<{ ok: boolean; marker: string }>(
128 r2Url,
129 "tanstack-live-value",
130 );
131 expect(put.ok).toBe(true);
132 // The env binding reached the server route.
133 expect(put.marker).toBe(marker);
134
135 const get = yield* fetchJsonReady<{
136 marker: string;
137 value: string | null;
138 }>(r2Url);
139 expect(get.value).toBe("tanstack-live-value");
140 expect(get.marker).toBe(marker);
141
142 // ── Static asset: the client bundle the SSR HTML references ──────
143 yield* expectClientScriptServes(site.url!);
144
145 yield* stack.destroy();
146 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
147 yield* waitForBucketToBeDeleted(bucket.bucketName, accountId);
148 }).pipe(logLevel),
149 { timeout: 360_000 },
150 );
151 },
152);
153
154const freshConn = HttpClient.mapRequest(
155 HttpClientRequest.setHeader("connection", "close"),
156);
157
158const fetchJsonReady = <T>(url: string) =>
159 Effect.gen(function* () {
160 const client = freshConn(yield* HttpClient.HttpClient);
161 return yield* client.get(url).pipe(
162 Effect.flatMap((res) =>
163 res.status === 200
164 ? Effect.flatMap(res.text, (body) =>
165 Effect.try({
166 try: () => JSON.parse(body) as T,
167 catch: () => new Error(`non-json body: ${body}`),
168 }),
169 )
170 : Effect.fail(new Error(`Worker not ready: ${res.status}`)),
171 ),
172 Effect.retry({
173 // Capped interval, ~90s total budget (workers.dev / DO propagation).
174 schedule: Schedule.min([
175 Schedule.exponential("500 millis"),
176 Schedule.spaced("2 seconds"),
177 ]),
178 times: 45,
179 }),
180 );
181 });
182
183const putTextJsonReady = <T>(url: string, body: string) =>
184 Effect.gen(function* () {
185 return yield* HttpClient.execute(
186 HttpClientRequest.put(url).pipe(
187 HttpClientRequest.bodyText(body, "text/plain"),
188 ),
189 ).pipe(
190 Effect.flatMap((res) =>
191 res.status === 200
192 ? Effect.flatMap(res.text, (responseBody) =>
193 Effect.try({
194 try: () => JSON.parse(responseBody) as T,
195 catch: () => new Error(`non-json body: ${responseBody}`),
196 }),
197 )
198 : Effect.fail(new Error(`Worker not ready: ${res.status}`)),
199 ),
200 Effect.retry({
201 // Capped interval, ~90s total budget (workers.dev / DO propagation).
202 schedule: Schedule.min([
203 Schedule.exponential("500 millis"),
204 Schedule.spaced("2 seconds"),
205 ]),
206 times: 45,
207 }),
208 );
209 });
210
211/**
212 * Discover a client `<script src="…​.js">` from the served SSR HTML and
213 * assert the referenced asset serves as JavaScript (not the Cloudflare
214 * placeholder page or an HTML error body). Re-discovers the script URL on
215 * every attempt — the bundle filename is content-addressed and the edge can
216 * briefly serve a stale index.
217 */
218const expectClientScriptServes = (siteUrl: string) =>
219 Effect.gen(function* () {
220 const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient);
221 yield* Effect.gen(function* () {
222 const res = yield* client.get(`${siteUrl}/`, {
223 urlParams: { __alchemy_cb: String(Date.now()) },
224 headers: { "cache-control": "no-cache", pragma: "no-cache" },
225 });
226 const html = yield* res.text;
227 const match = html.match(/<script[^>]+src="(\/[^"]+\.js[^"]*)"/i);
228 if (!match) {
229 return yield* Effect.fail(
230 new Error(`no client script tag in SSR HTML: ${html.slice(0, 200)}`),
231 );
232 }
233 const assetRes = yield* client.get(`${siteUrl}${match[1]}`);
234 const contentType = assetRes.headers["content-type"] ?? "";
235 const body = yield* assetRes.text;
236 if (!contentType.includes("javascript") || body.includes("<html")) {
237 return yield* Effect.fail(
238 new Error(
239 `asset ${match[1]} did not serve as JS: ${contentType} ${body.slice(0, 120)}`,
240 ),
241 );
242 }
243 }).pipe(
244 Effect.retry({
245 schedule: Schedule.min([
246 Schedule.exponential("500 millis", 1.5),
247 Schedule.spaced("5 seconds"),
248 ]),
249 times: 30,
250 }),
251 );
252 });
253
254const waitForBucketToBeDeleted = Effect.fn(function* (
255 bucketName: string,
256 accountId: string,
257) {
258 yield* r2
259 .getBucket({
260 accountId,
261 bucketName,
262 })
263 .pipe(
264 Effect.flatMap(() => Effect.fail(new BucketStillExists())),
265 Effect.retry({
266 while: (e): e is BucketStillExists => e instanceof BucketStillExists,
267 schedule: Schedule.max([
268 Schedule.min([
269 Schedule.exponential("200 millis"),
270 Schedule.spaced("2 seconds"),
271 ]),
272 Schedule.recurs(20),
273 ]),
274 }),
275 Effect.catchTag("NoSuchBucket", () => Effect.void),
276 );
277});
278
279class BucketStillExists extends Data.TaggedError("BucketStillExists") {}