1/**
2 * Regression test for serving a Vite site on a zone route with a path
3 * prefix.
4 *
5 * Cloudflare matches static assets against the FULL request pathname
6 * (https://developers.cloudflare.com/workers/static-assets/routing/advanced/serving-a-subdirectory/):
7 * a Worker routed at `zone/prefix*` only serves an asset for
8 * `/prefix/index.html` if the uploaded asset manifest contains that key.
9 * Vite's `base` (from the app's own vite.config.ts) rewrites the URLs the
10 * build emits, and Alchemy keys the uploaded manifest with the same
11 * resolved base — both halves are required for the site to work behind
12 * the route. The SPA fallback shell is aliased back to `/index.html`
13 * (Cloudflare resolves it at the manifest root, hard-coded), pinned here
14 * by the deep-link probe.
15 */
16import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
17import * as Cloudflare from "@/Cloudflare/index.ts";
18import { findZoneByName } from "@/Cloudflare/Zone/lookup";
19import * as Test from "@/Test/Alchemy";
20import * as dns from "@distilled.cloud/cloudflare/dns";
21import * as workers from "@distilled.cloud/cloudflare/workers";
22import { describe, expect } from "alchemy-test";
23import * as Data from "effect/Data";
24import * as Effect from "effect/Effect";
25import * as FileSystem from "effect/FileSystem";
26import * as Path from "effect/Path";
27import { MinimumLogLevel } from "effect/References";
28import * as Schedule from "effect/Schedule";
29import * as Stream from "effect/Stream";
30import * as pathe from "pathe";
31import { cloneFixture } from "../Utils/Fixture.ts";
32import { waitForWorkerToBeDeleted } from "../Utils/Worker.ts";
33
34const { test } = Test.make({ providers: Cloudflare.providers() });
35
36const logLevel = Effect.provideService(
37 MinimumLogLevel,
38 process.env.DEBUG ? "Debug" : "Info",
39);
40
41const spaFixtureDir = pathe.resolve(import.meta.dirname, "vite-spa-fixture");
42const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
43
44const zoneName =
45 process.env.CLOUDFLARE_TEST_WORKER_ROUTE_ZONE_NAME ?? "alchemy-test-2.us";
46
47// Deterministic per-user path prefix on the zone apex (never Date.now()).
48const routePrefix = `alchemy-vite-route-repro-${process.env.PULL_REQUEST ?? process.env.USER}`;
49const basePath = `/${routePrefix}/app`;
50const routePattern = `${zoneName}${basePath}*`;
51const routePageUrl = `https://${zoneName}${basePath}/`;
52
53const marker = "vite-route-prefix-repro";
54
55const htmlPage = `<!doctype html>
56<html lang="en">
57 <head>
58 <meta charset="UTF-8" />
59 <title>${marker}</title>
60 </head>
61 <body>
62 <div id="app">${marker}</div>
63 <script type="module" src="/src/main.ts"></script>
64 </body>
65</html>
66`;
67
68// Capped: an uncapped exponential over 8 recurs sums to ~2 minutes and turns
69// a persistent 403 into an apparent hang.
70const forbiddenRetrySchedule = Schedule.min([
71 Schedule.exponential("500 millis"),
72 Schedule.spaced("2 seconds"),
73]);
74
75const resolveZoneId = Effect.gen(function* () {
76 const { accountId } = yield* yield* CloudflareEnvironment;
77 const zone = yield* findZoneByName({ accountId, name: zoneName });
78 if (!zone) {
79 return yield* Effect.die(
80 new Error(`zone "${zoneName}" not found in account`),
81 );
82 }
83 return zone.id;
84});
85
86// Workers only run on proxied hostnames — the apex placeholder is standing
87// test-zone infrastructure (see WorkerRoutes.test.ts); ensure, never delete.
88const ensureApexPlaceholder = (zoneId: string) =>
89 Effect.gen(function* () {
90 const existing = yield* dns.listRecords.items({ zoneId }).pipe(
91 Stream.filter(
92 (r) => r.name === zoneName && (r.type === "A" || r.type === "AAAA"),
93 ),
94 Stream.runCollect,
95 Effect.map((chunk) => Array.from(chunk)[0]),
96 Effect.retry({
97 while: (e) => e._tag === "Forbidden",
98 schedule: forbiddenRetrySchedule,
99 times: 8,
100 }),
101 );
102 if (existing) return;
103 yield* dns.createRecord({
104 zoneId,
105 name: zoneName,
106 type: "AAAA",
107 content: "100::",
108 proxied: true,
109 ttl: 1,
110 comment: "standing placeholder so Workers routes serve on the zone apex",
111 });
112 });
113
114const purgeRoutes = (zoneId: string, ...patterns: string[]) =>
115 Effect.forEach(patterns, (pattern) =>
116 workers.listRoutes.items({ zoneId }).pipe(
117 Stream.filter((r) => r.pattern === pattern),
118 Stream.runCollect,
119 Effect.flatMap(
120 Effect.forEach((r) =>
121 workers
122 .deleteRoute({ zoneId, routeId: r.id })
123 .pipe(Effect.catch(() => Effect.void)),
124 ),
125 ),
126 Effect.retry({
127 while: (e) => e._tag === "Forbidden",
128 schedule: forbiddenRetrySchedule,
129 times: 8,
130 }),
131 ),
132 );
133
134class ProbeFailed extends Data.TaggedError("ProbeFailed")<{
135 url: string;
136 message: string;
137}> {}
138
139interface ProbeResult {
140 url: string;
141 status: number;
142 contentType: string | undefined;
143 body: string;
144}
145
146// One GET with cache busting; retried below until the edge stops serving
147// 5xx / Cloudflare error pages / stale-deploy 404s (route + worker + asset
148// propagation).
149const probeOnce = (url: string) =>
150 Effect.tryPromise({
151 try: async (signal): Promise<ProbeResult> => {
152 const u = new URL(url);
153 u.searchParams.set("__alchemy_cb", String(Date.now()));
154 const res = await fetch(u, {
155 signal,
156 cache: "no-store",
157 headers: { "cache-control": "no-cache", accept: "*/*" },
158 });
159 const body = await res.text();
160 return {
161 url,
162 status: res.status,
163 contentType: res.headers.get("content-type") ?? undefined,
164 body,
165 };
166 },
167 catch: (e) =>
168 new ProbeFailed({
169 url,
170 message: e instanceof Error ? e.message : String(e),
171 }),
172 });
173
174const isPropagating = (r: ProbeResult) =>
175 r.status !== 200 ||
176 r.body.includes("There is nothing here yet") ||
177 /Error\s+1\d{3}/i.test(r.body);
178
179const probeStable = (url: string) =>
180 probeOnce(url).pipe(
181 Effect.repeat({
182 schedule: Schedule.spaced("3 seconds"),
183 until: (r: ProbeResult) => !isPropagating(r),
184 // ~90s: workers.dev/zone-route propagation slows down when many
185 // concurrent deploys enable fresh subdomains at once.
186 times: 30,
187 }),
188 Effect.retry({
189 while: (e) => e._tag === "ProbeFailed",
190 schedule: Schedule.exponential("1 second"),
191 times: 5,
192 }),
193 );
194
195const excerpt = (s: string) => s.replace(/\s+/g, " ").slice(0, 200);
196
197/**
198 * End-to-end "does the SPA actually work here" check: the page must serve
199 * the marker HTML AND the module script it references must be fetchable
200 * (as JavaScript) from the same host.
201 */
202const evaluateSite = Effect.fn(function* (pageUrl: string, label: string) {
203 const page = yield* probeStable(pageUrl);
204 yield* Effect.log(
205 `[${label}] page ${page.status} ${page.contentType ?? "-"} :: ${excerpt(page.body)}`,
206 );
207 const src = page.body.match(/<script[^>]+src="([^"]+)"/)?.[1];
208 let script: ProbeResult | undefined;
209 if (page.status === 200 && src) {
210 const scriptUrl = new URL(src, pageUrl).toString();
211 script = yield* probeStable(scriptUrl);
212 yield* Effect.log(
213 `[${label}] script ${scriptUrl} -> ${script.status} ${script.contentType ?? "-"} :: ${excerpt(script.body)}`,
214 );
215 }
216 return { page, src, script };
217});
218
219// Tests are independent (per-test scratch stacks, private fixture clones),
220// so run them concurrently; suites are sequential by default.
221describe.concurrent(
222 "ViteRoutePrefix",
223 {
224 tags: [
225 "provider:cloudflare",
226 "provider:cloudflare:dns",
227 "provider:cloudflare:website",
228 "provider:cloudflare:worker",
229 "provider:cloudflare:zone",
230 "live",
231 ],
232 },
233 () => {
234 test.provider(
235 "Vite: base from vite.config.ts serves the site on a path-prefixed zone route",
236 (stack) =>
237 Effect.gen(function* () {
238 const { accountId } = yield* yield* CloudflareEnvironment;
239 const fs = yield* FileSystem.FileSystem;
240 const path = yield* Path.Path;
241 const zoneId = yield* resolveZoneId;
242
243 yield* stack.destroy();
244 yield* purgeRoutes(zoneId, routePattern);
245 yield* ensureApexPlaceholder(zoneId);
246
247 const rootDir = yield* cloneFixture(spaFixtureDir, {
248 prefix: "alchemy-vite-route-",
249 tempRoot,
250 entries: ["index.html", "package.json", "src"],
251 });
252 yield* fs.writeFileString(path.join(rootDir, "index.html"), htmlPage);
253 // The base lives in the app's own Vite config — the deploy adopts
254 // the resolved value; nothing is configured on the alchemy side.
255 yield* fs.writeFileString(
256 path.join(rootDir, "vite.config.ts"),
257 `import { defineConfig } from "vite";\n\nexport default defineConfig({ base: "${basePath}/" });\n`,
258 );
259 const memoInclude = [
260 "index.html",
261 "src/**",
262 "package.json",
263 "vite.config.ts",
264 ];
265
266 let workerName: string | undefined;
267
268 yield* Effect.gen(function* () {
269 const site = yield* stack.deploy(
270 Effect.gen(function* () {
271 return yield* Cloudflare.Website.Vite("ViteRoutePrefix", {
272 rootDir,
273 workersDev: true,
274 compatibility: {
275 date: "2024-09-23",
276 flags: ["nodejs_compat"],
277 },
278 memo: { include: memoInclude },
279 assets: { notFoundHandling: "single-page-application" },
280 routes: [{ pattern: routePattern, zoneName }],
281 });
282 }),
283 );
284 workerName = site.workerName;
285
286 expect(site.url).toBeDefined();
287 expect(site.routes).toHaveLength(1);
288 expect(site.routes[0]?.pattern).toEqual(routePattern);
289
290 // Control — the same manifest serves under the prefix on
291 // workers.dev too (the manifest is nested, not the route).
292 const control = yield* evaluateSite(
293 `${site.url!}${basePath}/`,
294 "workers.dev",
295 );
296 expect(control.page.status).toBe(200);
297 expect(control.page.body).toContain(marker);
298 // Vite `base` bakes the prefix into the emitted script URL.
299 expect(control.src).toContain(`${basePath}/`);
300 expect(control.script?.status).toBe(200);
301 expect(control.script?.contentType).toContain("javascript");
302
303 // The point of the feature: the site works end-to-end behind the
304 // path-prefixed zone route — HTML serves AND the module script it
305 // references resolves under the prefix (inside the route).
306 const routed = yield* evaluateSite(routePageUrl, "zone route");
307 expect(routed.page.status).toBe(200);
308 expect(routed.page.body).toContain(marker);
309 expect(routed.script?.status).toBe(200);
310 expect(routed.script?.contentType).toContain("javascript");
311 // `(hydrated)` is a string literal in the fixture's client module,
312 // so it proves the response is the real script — a 404 here would
313 // be answered by the SPA fallback with the shell, which contains
314 // the marker but never that literal.
315 expect(routed.script?.body).toContain("(hydrated)");
316 expect(
317 routed.script?.url.startsWith(`https://${zoneName}${basePath}/`),
318 ).toBe(true);
319
320 // Cloudflare's SPA fallback resolves `/index.html` at the manifest
321 // root, hard-coded — the shell is aliased back there so client-side
322 // routes under the base still boot the app.
323 const deep = yield* probeStable(
324 `https://${zoneName}${basePath}/deep/route`,
325 );
326 yield* Effect.log(
327 `[spa deep link] ${deep.status} ${deep.contentType ?? "-"} :: ${excerpt(deep.body)}`,
328 );
329 expect(deep.status).toBe(200);
330 expect(deep.body).toContain(marker);
331 }).pipe(
332 Effect.ensuring(
333 Effect.gen(function* () {
334 yield* stack.destroy().pipe(Effect.ignore);
335 yield* purgeRoutes(zoneId, routePattern).pipe(Effect.ignore);
336 if (workerName) {
337 yield* waitForWorkerToBeDeleted(workerName, accountId).pipe(
338 Effect.ignore,
339 );
340 }
341 }),
342 ),
343 );
344 }).pipe(logLevel),
345 { timeout: 360_000 },
346 );
347 },
348);