| 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 | */ |
| 16 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; |
| 17 | import * as Cloudflare from "@/Cloudflare/index.ts"; |
| 18 | import { findZoneByName } from "@/Cloudflare/Zone/lookup"; |
| 19 | import * as Test from "@/Test/Alchemy"; |
| 20 | import * as dns from "@distilled.cloud/cloudflare/dns"; |
| 21 | import * as workers from "@distilled.cloud/cloudflare/workers"; |
| 22 | import { describe, expect } from "alchemy-test"; |
| 23 | import * as Data from "effect/Data"; |
| 24 | import * as Effect from "effect/Effect"; |
| 25 | import * as FileSystem from "effect/FileSystem"; |
| 26 | import * as Path from "effect/Path"; |
| 27 | import { MinimumLogLevel } from "effect/References"; |
| 28 | import * as Schedule from "effect/Schedule"; |
| 29 | import * as Stream from "effect/Stream"; |
| 30 | import * as pathe from "pathe"; |
| 31 | import { cloneFixture } from "../Utils/Fixture.ts"; |
| 32 | import { waitForWorkerToBeDeleted } from "../Utils/Worker.ts"; |
| 33 | |
| 34 | const { test } = Test.make({ providers: Cloudflare.providers() }); |
| 35 | |
| 36 | const logLevel = Effect.provideService( |
| 37 | MinimumLogLevel, |
| 38 | process.env.DEBUG ? "Debug" : "Info", |
| 39 | ); |
| 40 | |
| 41 | const spaFixtureDir = pathe.resolve(import.meta.dirname, "vite-spa-fixture"); |
| 42 | const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp"); |
| 43 | |
| 44 | const 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()). |
| 48 | const routePrefix = `alchemy-vite-route-repro-${process.env.PULL_REQUEST ?? process.env.USER}`; |
| 49 | const basePath = `/${routePrefix}/app`; |
| 50 | const routePattern = `${zoneName}${basePath}*`; |
| 51 | const routePageUrl = `https://${zoneName}${basePath}/`; |
| 52 | |
| 53 | const marker = "vite-route-prefix-repro"; |
| 54 | |
| 55 | const 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. |
| 70 | const forbiddenRetrySchedule = Schedule.min([ |
| 71 | Schedule.exponential("500 millis"), |
| 72 | Schedule.spaced("2 seconds"), |
| 73 | ]); |
| 74 | |
| 75 | const 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. |
| 88 | const 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 | |
| 114 | const 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 | |
| 134 | class ProbeFailed extends Data.TaggedError("ProbeFailed")<{ |
| 135 | url: string; |
| 136 | message: string; |
| 137 | }> {} |
| 138 | |
| 139 | interface 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). |
| 149 | const 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 | |
| 174 | const 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 | |
| 179 | const 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 | |
| 195 | const 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 | */ |
| 202 | const 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. |
| 221 | describe.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 | ); |