1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import { isLocalId } from "@/Cloudflare/LocalRuntime";
4import * as Test from "@/Test/Alchemy";
5import { initialCwd } from "@/Util/Node.ts";
6import * as r2 from "@distilled.cloud/cloudflare/r2";
7import { describe, expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
11import * as FileSystem from "effect/FileSystem";
12import * as Path from "effect/Path";
13import { MinimumLogLevel } from "effect/References";
14import * as Schedule from "effect/Schedule";
15import * as HttpClient from "effect/http/HttpClient";
16import * as HttpClientRequest from "effect/http/HttpClientRequest";
17import * as pathe from "pathe";
18import { cloneFixture } from "../Utils/Fixture.ts";
19import { expectUrlContains } from "../Utils/Http.ts";
20import {
21 expectWorkerExists,
22 waitForWorkerToBeDeleted,
23} from "../Utils/Worker.ts";
24import type { Counter as ViteDoCounter } from "./vite-do-fixture/src/worker.ts";
25
26const { test } = Test.make({ providers: Cloudflare.providers() });
27const { test: devTest } = Test.make({
28 providers: Cloudflare.providers(),
29 dev: true,
30});
31
32const logLevel = Effect.provideService(
33 MinimumLogLevel,
34 process.env.DEBUG ? "Debug" : "Info",
35);
36
37const fixtureDir = pathe.resolve(import.meta.dirname, "vite-fixture");
38const spaFixtureDir = pathe.resolve(import.meta.dirname, "vite-spa-fixture");
39const foldkitFixtureDir = pathe.resolve(import.meta.dirname, "foldkit-fixture");
40const doFixtureDir = pathe.resolve(import.meta.dirname, "vite-do-fixture");
41const containerFixtureDir = pathe.resolve(
42 import.meta.dirname,
43 "vite-container-fixture",
44);
45const workerFirstFixtureDir = pathe.resolve(
46 import.meta.dirname,
47 "worker-first-fixture",
48);
49const reactRouterRscFixtureDir = pathe.resolve(
50 import.meta.dirname,
51 "react-router-rsc-fixture",
52);
53const tanstackDevBindingsFixtureDir = pathe.resolve(
54 import.meta.dirname,
55 "tanstack-dev-bindings-fixture",
56);
57const viteChildFixtureDir = pathe.resolve(
58 import.meta.dirname,
59 "vite-child-fixture",
60);
61
62// Vite/Rollup's `vite:build-html` plugin chokes when the project root
63// is outside the current working directory because it tries to express
64// the emitted asset path relative to `cwd`. To keep the temp clone
65// reachable via a sane relative path, allocate the temp dir *inside*
66// the alchemy package's `.tmp/` so it sits under the same workspace
67// root as `cwd`.
68const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
69
70// Suites are sequential by default; every test here deploys its own
71// scratch stack with its own worker names and fixture clone, so they are
72// independent and can run concurrently. Builds run in per-project child
73// processes (`runViteBuildChild`), so concurrent builds can't race each
74// other's working directory.
75describe.concurrent(
76 "Vite",
77 { tags: ["provider:cloudflare", "provider:cloudflare:website"] },
78 () => {
79 test.provider(
80 "Vite: editing a source file republishes the assets in a single deploy",
81 (stack) =>
82 Effect.gen(function* () {
83 const { accountId } = yield* yield* CloudflareEnvironment;
84 const fs = yield* FileSystem.FileSystem;
85 const path = yield* Path.Path;
86
87 yield* stack.destroy();
88
89 const rootDir = yield* cloneFixture(fixtureDir, {
90 prefix: "alchemy-vite-fix-",
91 tempRoot,
92 entries: ["index.html", "package.json", "vite.config.ts", "src"],
93 });
94 const indexPath = path.join(rootDir, "index.html");
95
96 // Restrict the input memo to fixture sources so the test isn't
97 // re-hashing the whole monorepo on every deploy.
98 const memoInclude = [
99 "index.html",
100 "src/**",
101 "package.json",
102 "vite.config.ts",
103 ];
104
105 const v1Marker = `vite-v1-${Date.now()}`;
106 yield* fs.writeFileString(indexPath, htmlPage(v1Marker));
107
108 const site1 = yield* stack.deploy(
109 Effect.gen(function* () {
110 return yield* Cloudflare.Website.Vite(
111 "FixVite",
112 viteProps(rootDir, memoInclude),
113 );
114 }),
115 );
116
117 expect(site1.url).toBeDefined();
118 expect(site1.hash?.input).toBeDefined();
119 yield* expectWorkerExists(site1.workerName, accountId);
120 yield* expectUrlContains(`${site1.url!}/`, v1Marker, {
121 timeout: "120 seconds",
122 label: "deploy1 v1 marker",
123 });
124
125 // ── deploy 2: edit fixture, redeploy once ──────────────────────────
126 const v2Marker = `vite-v2-${Date.now()}`;
127 yield* fs.writeFileString(indexPath, htmlPage(v2Marker));
128
129 const site2 = yield* stack.deploy(
130 Effect.gen(function* () {
131 return yield* Cloudflare.Website.Vite(
132 "FixVite",
133 viteProps(rootDir, memoInclude),
134 );
135 }),
136 );
137
138 expect(site2.hash?.input).toBeDefined();
139 expect(site2.hash?.input).not.toEqual(site1.hash?.input);
140 yield* expectUrlContains(`${site2.url!}/`, v2Marker, {
141 timeout: "60 seconds",
142 label: "deploy2 v2 marker",
143 });
144
145 yield* stack.destroy();
146 yield* waitForWorkerToBeDeleted(site1.workerName, accountId);
147 }).pipe(logLevel),
148 { tags: ["provider:cloudflare:worker", "live"], timeout: 360_000 },
149 );
150
151 // Regression test for https://github.com/alchemy-run/alchemy/issues/1016.
152 //
153 // `hashViteInput` used to pass a relative root as both the directory and its
154 // base, resolving `app` as `<cwd>/app/app`. The empty match produced a constant
155 // input hash, so source edits were incorrectly treated as no-ops.
156 test.provider(
157 "Vite: a source edit changes the input hash when rootDir is relative",
158 (stack) =>
159 Effect.gen(function* () {
160 const { accountId } = yield* yield* CloudflareEnvironment;
161 const fs = yield* FileSystem.FileSystem;
162 const path = yield* Path.Path;
163
164 yield* stack.destroy();
165
166 const absoluteRoot = yield* cloneFixture(fixtureDir, {
167 prefix: "alchemy-vite-relative-root-",
168 tempRoot,
169 entries: ["index.html", "package.json", "vite.config.ts", "src"],
170 });
171 // Relative to the process's anchored cwd (what a relative rootDir
172 // means to the engine) — a live `process.cwd()` read can race a
173 // concurrent tool's transient chdir.
174 const rootDir = path.relative(initialCwd, absoluteRoot);
175 const indexPath = path.join(absoluteRoot, "index.html");
176 const memoInclude = [
177 "index.html",
178 "src/**",
179 "package.json",
180 "vite.config.ts",
181 ];
182
183 expect(path.isAbsolute(rootDir)).toBe(false);
184 yield* fs.writeFileString(indexPath, htmlPage("relative-root-v1"));
185
186 const site1 = yield* stack.deploy(
187 Effect.gen(function* () {
188 return yield* Cloudflare.Website.Vite(
189 "ViteRelativeRoot",
190 viteProps(rootDir, memoInclude),
191 );
192 }),
193 );
194 expect(site1.hash?.input).toBeDefined();
195
196 yield* fs.writeFileString(indexPath, htmlPage("relative-root-v2"));
197
198 const site2 = yield* stack.deploy(
199 Effect.gen(function* () {
200 return yield* Cloudflare.Website.Vite(
201 "ViteRelativeRoot",
202 viteProps(rootDir, memoInclude),
203 );
204 }),
205 );
206 expect(site2.hash?.input).toBeDefined();
207 expect(site2.hash?.input).not.toEqual(site1.hash?.input);
208
209 yield* stack.destroy();
210 yield* waitForWorkerToBeDeleted(site1.workerName, accountId);
211 }).pipe(logLevel),
212 { tags: ["provider:cloudflare:worker", "live"], timeout: 360_000 },
213 );
214
215 // Regression test for https://github.com/alchemy-run/alchemy/issues/792.
216 //
217 // A pure client-only Vite project (no vite.config.ts, no plugins, no worker
218 // entry) resolves as `appType: "spa"`, so the Cloudflare Vite plugin declares
219 // no `builder.buildApp`. On Vite 8, `builder.buildApp()` then runs post-order
220 // `buildApp` hooks *before* the default environment builds — which used to make
221 // our build-output plugin resolve while the client output was still undefined,
222 // so the deploy died with "Vite build produced neither assets nor server
223 // output". Detecting completion in `writeBundle` (per environment) instead of a
224 // post-order `buildApp` hook fixes it; this test proves the SPA path deploys.
225 test.provider(
226 "Vite: client-only SPA (no config, no plugins) builds and serves assets",
227 (stack) =>
228 Effect.gen(function* () {
229 const { accountId } = yield* yield* CloudflareEnvironment;
230 const fs = yield* FileSystem.FileSystem;
231 const path = yield* Path.Path;
232
233 yield* stack.destroy();
234
235 const rootDir = yield* cloneFixture(spaFixtureDir, {
236 prefix: "alchemy-vite-spa-",
237 tempRoot,
238 entries: ["index.html", "package.json", "src"],
239 });
240 const indexPath = path.join(rootDir, "index.html");
241 const memoInclude = ["index.html", "src/**", "package.json"];
242
243 const marker = `vite-spa-${Date.now()}`;
244 yield* fs.writeFileString(indexPath, htmlPage(marker));
245
246 const site = yield* stack.deploy(
247 Effect.gen(function* () {
248 return yield* Cloudflare.Website.Vite(
249 "FixViteSpa",
250 viteProps(rootDir, memoInclude),
251 );
252 }),
253 );
254
255 expect(site.url).toBeDefined();
256 expect(site.hash?.input).toBeDefined();
257 yield* expectWorkerExists(site.workerName, accountId);
258 yield* expectUrlContains(`${site.url!}/`, marker, {
259 timeout: "120 seconds",
260 label: "spa marker",
261 });
262
263 yield* stack.destroy();
264 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
265 }).pipe(logLevel),
266 { tags: ["provider:cloudflare:worker", "live"], timeout: 360_000 },
267 );
268
269 // Foldkit (foldkit.dev) is an Effect-native Elm-architecture frontend
270 // framework. Its apps are plain client-only Vite projects (the Foldkit Vite
271 // plugin only adds HMR/devtools wiring), so `Cloudflare.Website.Vite` deploys
272 // them as-is. This pins that the Foldkit plugin composes with the injected
273 // Cloudflare Vite plugin and that deep links fall back to `index.html` via
274 // `single-page-application` not-found handling.
275 test.provider(
276 "Vite: Foldkit SPA deploys and serves with SPA fallback",
277 (stack) =>
278 Effect.gen(function* () {
279 const { accountId } = yield* yield* CloudflareEnvironment;
280
281 yield* stack.destroy();
282
283 const rootDir = yield* cloneFixture(foldkitFixtureDir, {
284 prefix: "alchemy-vite-foldkit-",
285 tempRoot,
286 entries: ["index.html", "package.json", "vite.config.ts", "src"],
287 });
288 const memoInclude = [
289 "index.html",
290 "src/**",
291 "package.json",
292 "vite.config.ts",
293 ];
294
295 const site = yield* stack.deploy(
296 Effect.gen(function* () {
297 return yield* Cloudflare.Website.Vite("FixViteFoldkit", {
298 ...viteProps(rootDir, memoInclude),
299 assets: {
300 notFoundHandling: "single-page-application",
301 },
302 });
303 }),
304 );
305
306 expect(site.url).toBeDefined();
307 expect(site.hash?.input).toBeDefined();
308 yield* expectWorkerExists(site.workerName, accountId);
309 yield* expectUrlContains(`${site.url!}/`, "Foldkit Fixture", {
310 timeout: "120 seconds",
311 label: "foldkit index",
312 });
313 // Deep link falls back to index.html so client-side routing can boot.
314 yield* expectUrlContains(
315 `${site.url!}/counter/42`,
316 "Foldkit Fixture",
317 {
318 timeout: "60 seconds",
319 label: "foldkit spa fallback",
320 },
321 );
322
323 yield* stack.destroy();
324 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
325 }).pipe(logLevel),
326 { tags: ["provider:cloudflare:worker", "live"], timeout: 360_000 },
327 );
328
329 test.provider(
330 "Vite: class form deploys and serves the built assets",
331 (stack) =>
332 Effect.gen(function* () {
333 const { accountId } = yield* yield* CloudflareEnvironment;
334 const fs = yield* FileSystem.FileSystem;
335 const path = yield* Path.Path;
336
337 yield* stack.destroy();
338
339 const rootDir = yield* cloneFixture(fixtureDir, {
340 prefix: "alchemy-vite-class-",
341 tempRoot,
342 entries: ["index.html", "package.json", "vite.config.ts", "src"],
343 });
344 const indexPath = path.join(rootDir, "index.html");
345 const memoInclude = [
346 "index.html",
347 "src/**",
348 "package.json",
349 "vite.config.ts",
350 ];
351
352 const marker = `vite-class-${Date.now()}`;
353 yield* fs.writeFileString(indexPath, htmlPage(marker));
354
355 const site1 = yield* stack.deploy(
356 Effect.gen(function* () {
357 return yield* class FixVite extends Cloudflare.Website.Vite<FixVite>()(
358 "FixVite",
359 viteProps(rootDir, memoInclude),
360 ) {};
361 }),
362 );
363
364 expect(site1.url).toBeDefined();
365 expect(site1.hash?.input).toBeDefined();
366 yield* expectWorkerExists(site1.workerName, accountId);
367 yield* expectUrlContains(`${site1.url!}/`, marker, {
368 timeout: "120 seconds",
369 label: "class form marker",
370 });
371
372 yield* stack.destroy();
373 yield* waitForWorkerToBeDeleted(site1.workerName, accountId);
374 }).pipe(logLevel),
375 { tags: ["provider:cloudflare:worker", "live"], timeout: 360_000 },
376 );
377
378 // ─────────────────────────────────────────────────────────────────────
379 // Path-relocation behavior for the vite path
380 //
381 // `Cloudflare.Website.Vite` stores a path-insensitive `hash.input` made from
382 // the memo'd input tree plus build-affecting Vite options. The diff is:
383 //
384 // `input !== output.hash?.input`
385 //
386 // — a pure content comparison that must be stable across rootDir
387 // moves. We delete the original rootDir between deploys to make the
388 // test fail loudly if anything still depends on the recorded path.
389 // ─────────────────────────────────────────────────────────────────────
390
391 test.provider(
392 "Vite: relocating rootDir (and deleting the old one) is a no-op when sources are identical",
393 (stack) =>
394 Effect.gen(function* () {
395 const { accountId } = yield* yield* CloudflareEnvironment;
396 const fs = yield* FileSystem.FileSystem;
397 const path = yield* Path.Path;
398
399 yield* stack.destroy();
400
401 const memoInclude = [
402 "index.html",
403 "src/**",
404 "package.json",
405 "vite.config.ts",
406 ];
407 const marker = `vite-relocate-${Date.now()}`;
408
409 const rootA = yield* cloneFixture(fixtureDir, {
410 prefix: "alchemy-vite-relocate-a-",
411 tempRoot,
412 entries: ["index.html", "package.json", "vite.config.ts", "src"],
413 });
414 yield* fs.writeFileString(
415 path.join(rootA, "index.html"),
416 htmlPage(marker),
417 );
418
419 const site1 = yield* stack.deploy(
420 Effect.gen(function* () {
421 return yield* Cloudflare.Website.Vite(
422 "ViteReloc",
423 viteProps(rootA, memoInclude),
424 );
425 }),
426 );
427 expect(site1.hash?.input).toBeDefined();
428 yield* expectUrlContains(`${site1.url!}/`, marker, {
429 timeout: "120 seconds",
430 label: "deploy1 marker",
431 });
432
433 // Drop rootA so a stale path comparison can't quietly succeed.
434 yield* fs.remove(rootA, { recursive: true });
435
436 const rootB = yield* cloneFixture(fixtureDir, {
437 prefix: "alchemy-vite-relocate-b-",
438 tempRoot,
439 entries: ["index.html", "package.json", "vite.config.ts", "src"],
440 });
441 yield* fs.writeFileString(
442 path.join(rootB, "index.html"),
443 htmlPage(marker),
444 );
445
446 const site2 = yield* stack.deploy(
447 Effect.gen(function* () {
448 return yield* Cloudflare.Website.Vite(
449 "ViteReloc",
450 viteProps(rootB, memoInclude),
451 );
452 }),
453 );
454
455 // Identical sources ⇒ identical input hash ⇒ diff says
456 // unchanged ⇒ no rebuild required for the apply to succeed.
457 expect(site2.hash?.input).toEqual(site1.hash?.input);
458 yield* expectUrlContains(`${site2.url!}/`, marker, {
459 timeout: "60 seconds",
460 label: "deploy2 marker",
461 });
462
463 yield* stack.destroy();
464 yield* waitForWorkerToBeDeleted(site1.workerName, accountId);
465 }).pipe(logLevel),
466 { tags: ["provider:cloudflare:worker", "live"], timeout: 360_000 },
467 );
468
469 // ─────────────────────────────────────────────────────────────────────
470 // Workspace-aware memoization
471 //
472 // A Vite project in a monorepo can bundle source from sibling workspace
473 // packages. Those directories are discovered from the module graph at
474 // build time, persisted in `hash.workspaces` (relative to the Vite
475 // root), and included in the `hash.input` computation — so editing only
476 // a sibling package busts the memo and triggers a rebuild.
477 // ─────────────────────────────────────────────────────────────────────
478
479 test.provider(
480 "Vite: edits in a sibling workspace package bust the build memo",
481 (stack) =>
482 Effect.gen(function* () {
483 const { accountId } = yield* yield* CloudflareEnvironment;
484 const fs = yield* FileSystem.FileSystem;
485 const path = yield* Path.Path;
486
487 yield* stack.destroy();
488
489 // Hand-rolled two-package workspace: `app` is the Vite root and
490 // `shared` is a sibling package whose source the client bundle
491 // imports directly (escaping the Vite root).
492 yield* fs.makeDirectory(tempRoot, { recursive: true });
493 const parent = yield* fs.makeTempDirectory({
494 prefix: "alchemy-vite-ws-",
495 directory: tempRoot,
496 });
497 yield* Effect.addFinalizer(
498 Exit.match({
499 onSuccess: () =>
500 Effect.ignore(fs.remove(parent, { recursive: true })),
501 onFailure: () => Effect.void,
502 }),
503 );
504 const rootDir = path.join(parent, "app");
505 const sharedDir = path.join(parent, "shared");
506
507 const writeShared = (marker: string) =>
508 fs.writeFileString(
509 path.join(sharedDir, "src/message.ts"),
510 `export const message = ${JSON.stringify(marker)};\n`,
511 );
512
513 yield* fs.makeDirectory(path.join(rootDir, "src"), {
514 recursive: true,
515 });
516 yield* fs.makeDirectory(path.join(sharedDir, "src"), {
517 recursive: true,
518 });
519 yield* fs.writeFileString(
520 path.join(rootDir, "index.html"),
521 htmlPage("vite-workspace-fixture"),
522 );
523 yield* fs.writeFileString(
524 path.join(rootDir, "package.json"),
525 JSON.stringify({
526 name: "alchemy-vite-workspace-app",
527 version: "0.0.0",
528 private: true,
529 type: "module",
530 }),
531 );
532 // Same shape as vite-fixture/vite.config.ts: point the SSR build at
533 // the minimal worker entry so the cloudflare-vite-plugin can wrap it.
534 yield* fs.writeFileString(
535 path.join(rootDir, "vite.config.ts"),
536 `import { defineConfig } from "vite";
537export default defineConfig({
538 environments: {
539 ssr: { build: { rollupOptions: { input: "./src/worker.ts" } } },
540 },
541});
542`,
543 );
544 yield* fs.writeFileString(
545 path.join(rootDir, "src/worker.ts"),
546 `type Env = { ASSETS: { fetch: (req: Request) => Promise<Response> } };
547export default {
548 fetch(request: Request, env: Env): Promise<Response> {
549 return env.ASSETS.fetch(request);
550 },
551};
552`,
553 );
554 // The client entry imports across the Vite root boundary into the
555 // sibling package's source.
556 yield* fs.writeFileString(
557 path.join(rootDir, "src/main.ts"),
558 `import { message } from "../../shared/src/message.ts";
559const el = document.getElementById("app");
560if (el) {
561 el.textContent = message;
562}
563`,
564 );
565 yield* fs.writeFileString(
566 path.join(sharedDir, "package.json"),
567 JSON.stringify({
568 name: "alchemy-vite-workspace-shared",
569 version: "0.0.0",
570 private: true,
571 type: "module",
572 }),
573 );
574
575 const memoInclude = [
576 "index.html",
577 "src/**",
578 "package.json",
579 "vite.config.ts",
580 ];
581
582 const marker1 = `vite-ws-1-${Date.now()}`;
583 yield* writeShared(marker1);
584
585 const site1 = yield* stack.deploy(
586 Effect.gen(function* () {
587 return yield* Cloudflare.Website.Vite(
588 "ViteWorkspace",
589 viteProps(rootDir, memoInclude),
590 );
591 }),
592 );
593
594 expect(site1.url).toBeDefined();
595 expect(site1.hash?.input).toBeDefined();
596 // The sibling package was discovered from the module graph and
597 // persisted relative to the Vite root.
598 expect(site1.hash?.additionalWorkspaces).toContain("../shared");
599 yield* expectWorkerExists(site1.workerName, accountId);
600 yield* expectBundleContains(site1.url!, marker1, {
601 label: "workspace marker v1 in client bundle",
602 });
603
604 // ── deploy 2: edit ONLY the sibling package ────────────────────────
605 const marker2 = `vite-ws-2-${Date.now()}`;
606 yield* writeShared(marker2);
607
608 const site2 = yield* stack.deploy(
609 Effect.gen(function* () {
610 return yield* Cloudflare.Website.Vite(
611 "ViteWorkspace",
612 viteProps(rootDir, memoInclude),
613 );
614 }),
615 );
616
617 // Nothing under `rootDir` changed — only the sibling workspace did.
618 // The workspace-aware input hash must still bust the memo.
619 expect(site2.hash?.input).toBeDefined();
620 expect(site2.hash?.input).not.toEqual(site1.hash?.input);
621 expect(site2.hash?.additionalWorkspaces).toContain("../shared");
622 yield* expectBundleContains(site2.url!, marker2, {
623 label: "workspace marker v2 in client bundle",
624 });
625
626 // ── deploy 3: no changes anywhere ⇒ memo hit ───────────────────────
627 const site3 = yield* stack.deploy(
628 Effect.gen(function* () {
629 return yield* Cloudflare.Website.Vite(
630 "ViteWorkspace",
631 viteProps(rootDir, memoInclude),
632 );
633 }),
634 );
635
636 expect(site3.hash?.input).toEqual(site2.hash?.input);
637 expect(site3.hash?.additionalWorkspaces).toEqual(
638 site2.hash?.additionalWorkspaces,
639 );
640
641 yield* stack.destroy();
642 yield* waitForWorkerToBeDeleted(site1.workerName, accountId);
643 }).pipe(logLevel),
644 { tags: ["provider:cloudflare:worker", "live"], timeout: 360_000 },
645 );
646
647 test.provider(
648 "Vite: `env` props are inlined and env-only changes redeploy",
649 (stack) =>
650 Effect.gen(function* () {
651 const { accountId } = yield* yield* CloudflareEnvironment;
652
653 yield* stack.destroy();
654
655 const rootDir = yield* cloneFixture(fixtureDir, {
656 prefix: "alchemy-vite-env-",
657 tempRoot,
658 entries: ["index.html", "package.json", "vite.config.ts", "src"],
659 });
660 const memoInclude = ["index.html", "src/**", "package.json"];
661 const marker1 = `vite-env-1-${Date.now()}`;
662
663 const site1 = yield* stack.deploy(
664 Effect.gen(function* () {
665 return yield* Cloudflare.Website.Vite("FixViteEnv", {
666 ...viteProps(rootDir, memoInclude),
667 env: { VITE_TEST_MARKER: marker1 },
668 });
669 }),
670 );
671
672 expect(site1.url).toBeDefined();
673 expect(site1.hash?.input).toBeDefined();
674 // Resolve the hashed bundle URL by reading the deployed HTML, then
675 // assert the marker that `main.ts` references via
676 // `import.meta.env.VITE_TEST_MARKER` was actually inlined into the
677 // served JS asset by `Cloudflare.Website.Vite`'s `env`-→-`define` plumbing.
678 yield* expectBundleContains(site1.url!, marker1, {
679 label: "VITE_TEST_MARKER v1 inlined into client bundle",
680 });
681
682 const marker2 = `vite-env-2-${Date.now()}`;
683 const site2 = yield* stack.deploy(
684 Effect.gen(function* () {
685 return yield* Cloudflare.Website.Vite("FixViteEnv", {
686 ...viteProps(rootDir, memoInclude),
687 env: { VITE_TEST_MARKER: marker2 },
688 });
689 }),
690 );
691
692 expect(site2.hash?.input).toBeDefined();
693 yield* expectBundleContains(site2.url!, marker2, {
694 label: "VITE_TEST_MARKER v2 inlined into client bundle",
695 });
696
697 yield* stack.destroy();
698 yield* waitForWorkerToBeDeleted(site1.workerName, accountId);
699 }).pipe(logLevel),
700 { tags: ["provider:cloudflare:worker", "live"], timeout: 360_000 },
701 );
702
703 test.provider(
704 "Vite: worker entry can host a local Durable Object binding",
705 (stack) =>
706 Effect.gen(function* () {
707 const { accountId } = yield* yield* CloudflareEnvironment;
708
709 yield* stack.destroy();
710
711 const rootDir = yield* cloneFixture(doFixtureDir, {
712 prefix: "alchemy-vite-do-",
713 tempRoot,
714 // Keep the fixture's real stack file available for local
715 // `alchemy dev` smoke tests. The live deploy below uses an inline
716 // stack so cleanup stays under the provider test harness.
717 entries: [
718 "alchemy.run.ts",
719 "index.html",
720 "package.json",
721 "vite.config.ts",
722 "src",
723 ],
724 });
725 const memoInclude = [
726 "index.html",
727 "src/**",
728 "package.json",
729 "vite.config.ts",
730 ];
731
732 const site = yield* stack.deploy(
733 Effect.gen(function* () {
734 return yield* Cloudflare.Website.Vite("ViteDo", {
735 ...viteProps(rootDir, memoInclude),
736 compatibility: {
737 date: "2026-03-17",
738 flags: ["nodejs_compat"],
739 },
740 // The documented SPA + Worker-API pattern (vite.mdx): the SPA
741 // fallback owns unmatched paths while the glob pins the API
742 // namespace to the Worker regardless of request mode.
743 assets: {
744 notFoundHandling: "single-page-application",
745 runWorkerFirst: ["/api/*"],
746 },
747 env: {
748 Counter: Cloudflare.DurableObject<ViteDoCounter>("Counter", {
749 className: "Counter",
750 }),
751 },
752 });
753 }),
754 );
755
756 expect(site.url).toBeDefined();
757 yield* expectWorkerExists(site.workerName, accountId);
758 yield* expectUrlContains(`${site.url!}/`, "Vite DO fixture", {
759 timeout: "120 seconds",
760 label: "vite do fixture assets",
761 });
762
763 // A deep link matches no asset: the SPA fallback serves the shell
764 // (static routing applies it to every request mode, so a plain GET
765 // with no Sec-Fetch-Mode header gets the shell too).
766 yield* expectUrlContains(
767 `${site.url!}/deep/link`,
768 "Vite DO fixture",
769 {
770 timeout: "60 seconds",
771 label: "vite do spa deep link",
772 },
773 );
774
775 const reset = yield* fetchJsonReady<{ ok: boolean }>(
776 `${site.url!}/api/reset`,
777 );
778 expect(reset.ok).toBe(true);
779
780 const first = yield* fetchJsonReady<{ count: number }>(
781 `${site.url!}/api/count`,
782 );
783 expect(first.count).toBe(1);
784
785 const second = yield* fetchJsonReady<{ count: number }>(
786 `${site.url!}/api/count`,
787 );
788 expect(second.count).toBe(2);
789
790 yield* stack.destroy();
791 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
792 }).pipe(logLevel),
793 { tags: ["provider:cloudflare:worker", "live"], timeout: 360_000 },
794 );
795
796 test.provider(
797 "Vite: Container binding on env deploys a container-backed DO class",
798 (stack) =>
799 Effect.gen(function* () {
800 const { accountId } = yield* yield* CloudflareEnvironment;
801
802 yield* stack.destroy();
803
804 const rootDir = yield* cloneFixture(containerFixtureDir, {
805 prefix: "alchemy-vite-container-",
806 tempRoot,
807 entries: ["index.html", "package.json", "vite.config.ts", "src"],
808 });
809 const memoInclude = [
810 "index.html",
811 "src/**",
812 "package.json",
813 "vite.config.ts",
814 ];
815
816 // A `Cloudflare.Container` declaration on `env` is Effect-shaped —
817 // before #997 the Vite build's env resolution ran it as an inlined
818 // env Effect and the deploy died before the build started.
819 const site = yield* stack.deploy(
820 Effect.gen(function* () {
821 return yield* Cloudflare.Website.Vite("ViteContainer", {
822 ...viteProps(rootDir, memoInclude),
823 compatibility: {
824 date: "2026-03-17",
825 flags: ["nodejs_compat"],
826 },
827 assets: {
828 runWorkerFirst: ["/api/*"],
829 },
830 env: {
831 ECHO: Cloudflare.Container("ViteEchoContainer", {
832 className: "EchoObject",
833 image: "mendhak/http-https-echo:latest",
834 observability: { logs: { enabled: true } },
835 }),
836 },
837 });
838 }),
839 );
840
841 expect(site.url).toBeDefined();
842 yield* expectWorkerExists(site.workerName, accountId);
843 yield* expectUrlContains(`${site.url!}/`, "Vite Container fixture", {
844 timeout: "120 seconds",
845 label: "vite container fixture assets",
846 });
847
848 // The echo image reflects the request as JSON ("method" only appears
849 // in a real echo response, never in an error page) — proof the request
850 // went Worker → DO class → container port 8080 and back.
851 const echo = yield* fetchContainerReady(
852 `${site.url!}/api/echo`,
853 "method",
854 );
855 expect(echo).toContain("method");
856
857 yield* stack.destroy();
858 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
859 }).pipe(logLevel),
860 // Container image pull + push + rollout comfortably exceeds the plain
861 // Vite deploy budget (mirrors Container.test.ts).
862 {
863 tags: [
864 "provider:cloudflare:container",
865 "provider:cloudflare:worker",
866 "live",
867 ],
868 timeout: 600_000,
869 },
870 );
871
872 // The documented interception pattern (solidstart.mdx hand-rolled SSR /
873 // static-site.mdx docs-site worker): the client build emits an index.html
874 // that would shadow `/` under assets-first routing, so the worker runs
875 // first, renders every page, and delegates real static files to the
876 // ASSETS binding itself.
877 test.provider(
878 "Vite: runWorkerFirst true intercepts asset paths and delegates to ASSETS",
879 (stack) =>
880 Effect.gen(function* () {
881 const { accountId } = yield* yield* CloudflareEnvironment;
882
883 yield* stack.destroy();
884
885 const rootDir = yield* cloneFixture(workerFirstFixtureDir, {
886 prefix: "alchemy-vite-worker-first-",
887 tempRoot,
888 entries: [
889 "index.html",
890 "package.json",
891 "public",
892 "src",
893 "vite.config.ts",
894 "worker.ts",
895 ],
896 });
897 const memoInclude = [
898 "index.html",
899 "public/**",
900 "src/**",
901 "package.json",
902 "vite.config.ts",
903 "worker.ts",
904 ];
905
906 const site = yield* stack.deploy(
907 Effect.gen(function* () {
908 return yield* Cloudflare.Website.Vite("ViteWorkerFirst", {
909 ...viteProps(rootDir, memoInclude),
910 main: "worker.ts",
911 assets: { runWorkerFirst: true },
912 });
913 }),
914 );
915
916 expect(site.url).toBeDefined();
917 yield* expectWorkerExists(site.workerName, accountId);
918
919 // `/` matches the built index.html asset, but the worker runs first
920 // and renders — the raw template must never serve.
921 yield* expectUrlContains(`${site.url!}/`, "worker-first-rendered:/", {
922 timeout: "120 seconds",
923 label: "worker-first render at /",
924 });
925 const home = yield* fetchTextReady(`${site.url!}/`);
926 expect(home).not.toContain("worker-first-raw-template");
927
928 // Any other route renders too — the worker sees every request.
929 yield* expectUrlContains(
930 `${site.url!}/some/page`,
931 "worker-first-rendered:/some/page",
932 { timeout: "60 seconds", label: "worker-first render deep" },
933 );
934
935 // Real static files still serve, through the worker's own ASSETS
936 // delegation.
937 yield* expectUrlContains(
938 `${site.url!}/robots.txt`,
939 "worker-first-static-asset",
940 { timeout: "60 seconds", label: "worker-first ASSETS delegation" },
941 );
942
943 yield* stack.destroy();
944 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
945 }).pipe(logLevel),
946 { tags: ["provider:cloudflare:worker", "live"], timeout: 360_000 },
947 );
948
949 test.provider(
950 "Vite: main overrides the worker entry from the Vite config",
951 (stack) =>
952 Effect.gen(function* () {
953 const { accountId } = yield* yield* CloudflareEnvironment;
954
955 yield* stack.destroy();
956
957 const rootDir = yield* cloneFixture(doFixtureDir, {
958 prefix: "alchemy-vite-main-",
959 tempRoot,
960 entries: [
961 "alchemy.run.ts",
962 "index.html",
963 "package.json",
964 "vite.config.ts",
965 "src",
966 ],
967 });
968 const memoInclude = [
969 "index.html",
970 "src/**",
971 "package.json",
972 "vite.config.ts",
973 ];
974
975 // The fixture's vite.config.ts points the ssr environment at
976 // `src/worker.ts`. `main` must take precedence and deploy
977 // `src/worker-main.ts`, which re-exports the Durable Object and
978 // additionally answers `/api/entry`.
979 const site = yield* stack.deploy(
980 Effect.gen(function* () {
981 return yield* Cloudflare.Website.Vite("ViteMain", {
982 ...viteProps(rootDir, memoInclude),
983 main: "src/worker-main.ts",
984 compatibility: {
985 date: "2026-03-17",
986 flags: ["nodejs_compat"],
987 },
988 assets: {
989 runWorkerFirst: ["/api/*"],
990 },
991 env: {
992 Counter: Cloudflare.DurableObject<ViteDoCounter>("Counter", {
993 className: "Counter",
994 }),
995 },
996 });
997 }),
998 );
999
1000 expect(site.url).toBeDefined();
1001 yield* expectWorkerExists(site.workerName, accountId);
1002
1003 const entry = yield* fetchJsonReady<{ entry: string }>(
1004 `${site.url!}/api/entry`,
1005 );
1006 expect(entry.entry).toBe("worker-main");
1007
1008 const reset = yield* fetchJsonReady<{ ok: boolean }>(
1009 `${site.url!}/api/reset`,
1010 );
1011 expect(reset.ok).toBe(true);
1012
1013 const first = yield* fetchJsonReady<{ count: number }>(
1014 `${site.url!}/api/count`,
1015 );
1016 expect(first.count).toBe(1);
1017
1018 yield* stack.destroy();
1019 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
1020 }).pipe(logLevel),
1021 { tags: ["provider:cloudflare:worker", "live"], timeout: 360_000 },
1022 );
1023
1024 test.provider(
1025 "Vite: React Router RSC deploys from a distilled manifest",
1026 (stack) =>
1027 Effect.gen(function* () {
1028 const { accountId } = yield* yield* CloudflareEnvironment;
1029
1030 yield* stack.destroy();
1031
1032 const rootDir = yield* cloneFixture(reactRouterRscFixtureDir, {
1033 prefix: "alchemy-vite-rsc-",
1034 tempRoot,
1035 // Keep the fixture's stack file available for local `alchemy dev`
1036 // smoke tests. The live deploy below uses an inline stack so cleanup
1037 // stays under the provider test harness.
1038 entries: [
1039 "alchemy.run.ts",
1040 "app",
1041 "package.json",
1042 "react-router-vite",
1043 "tsconfig.json",
1044 "vite.config.ts",
1045 ],
1046 });
1047 const memoInclude = [
1048 "app/**",
1049 "react-router-vite/**",
1050 "package.json",
1051 "tsconfig.json",
1052 "vite.config.ts",
1053 ];
1054 const compatibility = {
1055 date: "2026-03-10",
1056 flags: ["nodejs_compat"],
1057 };
1058 const viteEnvironments = { entry: "rsc", children: ["ssr"] };
1059
1060 const site = yield* stack.deploy(
1061 Effect.gen(function* () {
1062 return yield* Cloudflare.Website.Vite("ReactRouterRsc", {
1063 ...viteProps(rootDir, memoInclude),
1064 // No `assets` config: the RSC build emits no index.html (HTML
1065 // is server-rendered), so SSR routes fall through to the RSC
1066 // handler while hydration assets serve from the asset layer.
1067 // `runWorkerFirst: true` would route `/assets/*` into the RSC
1068 // handler, which cannot serve them.
1069 compatibility,
1070 viteEnvironments,
1071 });
1072 }),
1073 );
1074
1075 expect(site.url).toBeDefined();
1076 yield* expectWorkerExists(site.workerName, accountId);
1077 yield* expectUrlContains(`${site.url!}/`, "React Router Vite", {
1078 timeout: "120 seconds",
1079 label: "react router rsc home route",
1080 });
1081 yield* expectUrlContains(`${site.url!}/about`, "About", {
1082 timeout: "60 seconds",
1083 label: "react router rsc client route",
1084 });
1085
1086 // The hydration bundle the SSR HTML references must serve from the
1087 // asset layer — the routing gap that let `runWorkerFirst: true`
1088 // masquerade as live-tested (the old test never fetched an asset).
1089 const home = yield* fetchTextReady(`${site.url!}/`);
1090 const assetPath = home.match(/\/assets\/[^"']+\.js/)?.[0];
1091 expect(assetPath).toBeDefined();
1092 const bundle = yield* fetchTextReady(`${site.url!}${assetPath}`);
1093 expect(bundle.length).toBeGreaterThan(0);
1094 expect(bundle).not.toContain("<html");
1095
1096 const render = yield* fetchJsonReady<{ ok: boolean; html: string }>(
1097 `${site.url!}/worker-render`,
1098 );
1099 expect(render.ok).toBe(true);
1100 expect(render.html).toContain(
1101 "Worker render via the ssr environment.",
1102 );
1103
1104 yield* stack.destroy();
1105 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
1106 }).pipe(logLevel),
1107 { tags: ["provider:cloudflare:worker", "live"], timeout: 360_000 },
1108 );
1109
1110 devTest.provider(
1111 "Vite dev: each app runs in a child rooted at its own cwd",
1112 (stack) =>
1113 Effect.gen(function* () {
1114 const fs = yield* FileSystem.FileSystem;
1115 const path = yield* Path.Path;
1116 yield* stack.destroy();
1117
1118 const entries = [
1119 "index.html",
1120 "package.json",
1121 "vite.config.ts",
1122 "worker.ts",
1123 ];
1124 const clone = (prefix: string) =>
1125 cloneFixture(viteChildFixtureDir, { prefix, tempRoot, entries });
1126 const [rootA, rootB] = yield* Effect.all(
1127 [clone("alchemy-vite-child-a-"), clone("alchemy-vite-child-b-")],
1128 { concurrency: "unbounded" },
1129 );
1130 yield* Effect.all([
1131 fs.writeFileString(
1132 path.join(rootA, "index.html"),
1133 htmlPage("child-app-a"),
1134 ),
1135 fs.writeFileString(
1136 path.join(rootB, "index.html"),
1137 htmlPage("child-app-b"),
1138 ),
1139 ]);
1140
1141 const deployed = yield* stack.deploy(
1142 Effect.gen(function* () {
1143 const props = (rootDir: string) => ({
1144 ...viteProps(rootDir, entries),
1145 main: "worker.ts",
1146 assets: {
1147 notFoundHandling: "single-page-application" as const,
1148 },
1149 dev: { port: 0 },
1150 });
1151 const appA = yield* Cloudflare.Website.Vite(
1152 "ViteChildA",
1153 props(rootA),
1154 );
1155 const appB = yield* Cloudflare.Website.Vite(
1156 "ViteChildB",
1157 props(rootB),
1158 );
1159 const defaultPortWorker = yield* Cloudflare.Worker(
1160 "ViteChildDefaultPortWorker",
1161 {
1162 script:
1163 'export default { fetch: () => new Response("default-port-worker") };',
1164 },
1165 );
1166 return { appA, appB, defaultPortWorker };
1167 }),
1168 );
1169
1170 expect(deployed.appA.url).not.toBe(deployed.appB.url);
1171 yield* Effect.all(
1172 [
1173 expectUrlContains(
1174 deployed.defaultPortWorker.url!,
1175 "default-port-worker",
1176 {
1177 timeout: "30 seconds",
1178 label: "default-port worker",
1179 },
1180 ),
1181 expectUrlContains(deployed.appA.url!, "child-app-a", {
1182 timeout: "30 seconds",
1183 label: "child A HTML",
1184 }),
1185 expectUrlContains(deployed.appB.url!, "child-app-b", {
1186 timeout: "30 seconds",
1187 label: "child B HTML",
1188 }),
1189 expectUrlContains(deployed.appA.url!, rootA, {
1190 timeout: "30 seconds",
1191 label: "child A cwd",
1192 }),
1193 expectUrlContains(deployed.appB.url!, rootB, {
1194 timeout: "30 seconds",
1195 label: "child B cwd",
1196 }),
1197 ],
1198 { concurrency: "unbounded" },
1199 );
1200 }).pipe(
1201 Effect.ensuring(
1202 stack.destroy().pipe(Effect.orDie).pipe(Effect.ignore),
1203 ),
1204 logLevel,
1205 ),
1206 { tags: ["provider:cloudflare:worker", "local"], timeout: 120_000 },
1207 );
1208
1209 devTest.provider(
1210 "Vite dev: client-only SPA serves the shell for deep links",
1211 (stack) =>
1212 Effect.gen(function* () {
1213 const fs = yield* FileSystem.FileSystem;
1214 const path = yield* Path.Path;
1215 yield* stack.destroy();
1216
1217 const rootDir = yield* cloneFixture(spaFixtureDir, {
1218 prefix: "alchemy-vite-spa-dev-",
1219 tempRoot,
1220 entries: ["index.html", "package.json", "src"],
1221 });
1222 const marker = "vite-spa-dev-marker";
1223 yield* fs.writeFileString(
1224 path.join(rootDir, "index.html"),
1225 htmlPage(marker),
1226 );
1227
1228 const site = yield* stack.deploy(
1229 Effect.gen(function* () {
1230 return yield* Cloudflare.Website.Vite("ViteSpaDev", {
1231 ...viteProps(rootDir, ["index.html", "src/**", "package.json"]),
1232 assets: {
1233 notFoundHandling: "single-page-application" as const,
1234 },
1235 dev: { port: 0 },
1236 });
1237 }),
1238 );
1239
1240 // Local identity: the url points at the alchemy dev proxy — no
1241 // cloud Worker exists.
1242 expect(site.url).toBeDefined();
1243 expect(site.url).toMatch(/^http:\/\/localhost:\d+/);
1244
1245 // The SPA shell serves at the root...
1246 yield* expectUrlContains(`${site.url!}/`, marker, {
1247 timeout: "60 seconds",
1248 label: "spa dev shell",
1249 });
1250 // ...and a deep link to an unregistered route falls back to the
1251 // shell so client-side routing can boot.
1252 yield* expectUrlContains(`${site.url!}/deep/link/route`, marker, {
1253 timeout: "30 seconds",
1254 label: "spa dev deep link fallback",
1255 });
1256 // The dev server serves the client module the shell references.
1257 yield* expectUrlContains(`${site.url!}/src/main.ts`, "hydrated", {
1258 timeout: "30 seconds",
1259 label: "spa dev module asset",
1260 });
1261 }).pipe(
1262 Effect.ensuring(
1263 stack.destroy().pipe(Effect.orDie).pipe(Effect.ignore),
1264 ),
1265 logLevel,
1266 ),
1267 { tags: ["local"], timeout: 180_000 },
1268 );
1269
1270 devTest.provider(
1271 "Vite dev: TanStack Start keeps Alchemy-managed R2 bindings",
1272 (stack) =>
1273 Effect.gen(function* () {
1274 const { accountId } = yield* yield* CloudflareEnvironment;
1275 const fs = yield* FileSystem.FileSystem;
1276 const path = yield* Path.Path;
1277 const bucketNames = new Set<string>();
1278
1279 const cleanup = Effect.gen(function* () {
1280 yield* stack.destroy();
1281 for (const bucketName of bucketNames) {
1282 yield* waitForBucketToBeDeleted(bucketName, accountId);
1283 }
1284 });
1285
1286 const body = Effect.gen(function* () {
1287 yield* stack.destroy();
1288
1289 const rootDir = yield* cloneFixture(tanstackDevBindingsFixtureDir, {
1290 prefix: "alchemy-tanstack-dev-bindings-",
1291 tempRoot,
1292 entries: [
1293 "alchemy.run.ts",
1294 "package.json",
1295 "tsconfig.json",
1296 "vite.config.ts",
1297 "src",
1298 ],
1299 });
1300 const indexRoutePath = path.join(rootDir, "src/routes/index.tsx");
1301 const memoInclude = [
1302 "src/**",
1303 "package.json",
1304 "tsconfig.json",
1305 "vite.config.ts",
1306 "alchemy.run.ts",
1307 ];
1308 const key = `dev-binding-${Date.now()}.txt`;
1309
1310 yield* fs.writeFileString(
1311 indexRoutePath,
1312 tanstackIndexRouteSource("hmr-marker-v1"),
1313 );
1314
1315 const deploy = (bucketId: string, marker: string) =>
1316 stack.deploy(
1317 Effect.gen(function* () {
1318 const bucket = yield* Cloudflare.R2.Bucket(bucketId, {
1319 forceDestroy: true,
1320 });
1321 const worker = yield* Cloudflare.Website.Vite(
1322 "TanStackDevBindings",
1323 {
1324 ...viteProps(rootDir, memoInclude),
1325 assets: {
1326 runWorkerFirst: true,
1327 },
1328 dev: {
1329 port: 0,
1330 },
1331 env: {
1332 BUCKET: bucket,
1333 DEV_MARKER: marker,
1334 },
1335 },
1336 );
1337 return { bucket, worker };
1338 }),
1339 );
1340
1341 const first = yield* deploy("DevBucketA", "dev-marker-v1");
1342 bucketNames.add(first.bucket.bucketName);
1343 expect(first.worker.url).toBeDefined();
1344 const r2Url = (base: string) =>
1345 joinUrl(base, `/api/r2?key=${encodeURIComponent(key)}`);
1346 yield* expectUrlContains(
1347 joinUrl(first.worker.url!, "/"),
1348 "hmr-marker-v1",
1349 {
1350 timeout: "30 seconds",
1351 label: "tanstack dev initial route",
1352 },
1353 );
1354
1355 const env1 = yield* fetchJsonReady<{ marker: string }>(
1356 r2Url(first.worker.url!),
1357 );
1358 expect(env1.marker).toBe("dev-marker-v1");
1359
1360 const put1 = yield* putTextJsonReady<{ ok: boolean }>(
1361 r2Url(first.worker.url!),
1362 "from-a",
1363 );
1364 expect(put1.ok).toBe(true);
1365
1366 const get1 = yield* fetchJsonReady<{ value: string | null }>(
1367 r2Url(first.worker.url!),
1368 );
1369 expect(get1.value).toBe("from-a");
1370
1371 // Change only a TanStack route file. The stack is not re-applied; the
1372 // local Vite server should render the updated route through the same
1373 // Alchemy proxy.
1374 yield* fs.writeFileString(
1375 indexRoutePath,
1376 tanstackIndexRouteSource("hmr-marker-v2"),
1377 );
1378 yield* expectUrlContains(
1379 joinUrl(first.worker.url!, "/"),
1380 "hmr-marker-v2",
1381 {
1382 timeout: "30 seconds",
1383 label: "tanstack dev updated route",
1384 },
1385 );
1386
1387 const second = yield* deploy("DevBucketB", "dev-marker-v2");
1388 bucketNames.add(second.bucket.bucketName);
1389 expect(second.worker.url).toBe(first.worker.url);
1390
1391 const env2 = yield* fetchJsonReady<{ marker: string }>(
1392 r2Url(second.worker.url!),
1393 );
1394 expect(env2.marker).toBe("dev-marker-v2");
1395
1396 // The Worker was rebound to DevBucketB. The object written through
1397 // DevBucketA should not be visible through the new binding.
1398 const reboundRead = yield* fetchJsonReady<{ value: string | null }>(
1399 r2Url(second.worker.url!),
1400 );
1401 expect(reboundRead.value).toBeNull();
1402
1403 const put2 = yield* putTextJsonReady<{ ok: boolean }>(
1404 r2Url(second.worker.url!),
1405 "from-b",
1406 );
1407 expect(put2.ok).toBe(true);
1408
1409 const get2 = yield* fetchJsonReady<{ value: string | null }>(
1410 r2Url(second.worker.url!),
1411 );
1412 expect(get2.value).toBe("from-b");
1413 });
1414
1415 const exit = yield* Effect.exit(body);
1416 if (Exit.isSuccess(exit)) {
1417 yield* cleanup;
1418 return exit.value;
1419 }
1420
1421 yield* cleanup.pipe(
1422 Effect.tapError((error) =>
1423 Effect.logError("Vite dev live test cleanup failed", error),
1424 ),
1425 Effect.ignore,
1426 );
1427 return yield* Effect.failCause(exit.cause);
1428 }).pipe(logLevel),
1429 { tags: ["provider:cloudflare:r2", "local"], timeout: 360_000 },
1430 );
1431 },
1432);
1433
1434const freshConn = HttpClient.mapRequest(
1435 HttpClientRequest.setHeader("connection", "close"),
1436);
1437
1438// Both providers return `worker.url` as a bare origin these days, but an
1439// external dev server URL parsed from stdout can still carry a trailing
1440// slash. Join without producing a `//` path that the dev server's router
1441// won't match.
1442const joinUrl = (base: string, path: string) =>
1443 `${base.replace(/\/+$/, "")}${path}`;
1444
1445const fetchTextReady = (url: string) =>
1446 Effect.gen(function* () {
1447 const client = freshConn(yield* HttpClient.HttpClient);
1448 return yield* client.get(url).pipe(
1449 Effect.flatMap((res) =>
1450 res.status === 200
1451 ? res.text
1452 : Effect.fail(new Error(`Worker not ready: ${res.status}`)),
1453 ),
1454 Effect.retry({
1455 // Capped interval, ~90s total budget (workers.dev propagation).
1456 schedule: Schedule.min([
1457 Schedule.exponential("500 millis"),
1458 Schedule.spaced("2 seconds"),
1459 ]),
1460 times: 45,
1461 }),
1462 Effect.orDie,
1463 );
1464 });
1465
1466const fetchJsonReady = <T>(url: string) =>
1467 Effect.gen(function* () {
1468 const client = freshConn(yield* HttpClient.HttpClient);
1469 return yield* client.get(url).pipe(
1470 Effect.flatMap((res) =>
1471 res.status === 200
1472 ? Effect.flatMap(res.text, (body) =>
1473 Effect.try({
1474 try: () => JSON.parse(body) as T,
1475 catch: () => new Error(`non-json body: ${body}`),
1476 }),
1477 )
1478 : Effect.fail(new Error(`Worker not ready: ${res.status}`)),
1479 ),
1480 Effect.retry({
1481 // Capped interval, ~90s total budget (workers.dev / DO propagation).
1482 schedule: Schedule.min([
1483 Schedule.exponential("500 millis"),
1484 Schedule.spaced("2 seconds"),
1485 ]),
1486 times: 45,
1487 }),
1488 );
1489 });
1490
1491// Retry a container-backed route until it answers 200 with a body containing
1492// `expected` — the container's first request rides through image provisioning
1493// and instance cold start, which comfortably outlasts fetchJsonReady's budget
1494// (mirrors Container.test.ts's readiness poll).
1495const fetchContainerReady = (url: string, expected: string) =>
1496 Effect.gen(function* () {
1497 const client = freshConn(yield* HttpClient.HttpClient);
1498 return yield* client.get(url).pipe(
1499 Effect.flatMap((res) =>
1500 res.text.pipe(
1501 Effect.flatMap((body) =>
1502 res.status === 200 && body.includes(expected)
1503 ? Effect.succeed(body)
1504 : Effect.fail(
1505 new Error(
1506 `container not ready: ${res.status} ${body.slice(0, 200)}`,
1507 ),
1508 ),
1509 ),
1510 ),
1511 ),
1512 Effect.timeout("10 seconds"),
1513 Effect.retry({
1514 // Cap exponential backoff at 3s — snappy fast path without the
1515 // geometric blow-up dominating wall time on a slow rollout.
1516 schedule: Schedule.min([
1517 Schedule.exponential("500 millis"),
1518 Schedule.spaced("3 seconds"),
1519 ]),
1520 times: 60,
1521 }),
1522 );
1523 });
1524
1525const putTextJsonReady = <T>(url: string, body: string) =>
1526 Effect.gen(function* () {
1527 return yield* HttpClient.execute(
1528 HttpClientRequest.put(url).pipe(
1529 HttpClientRequest.bodyText(body, "text/plain"),
1530 ),
1531 ).pipe(
1532 Effect.flatMap((res) =>
1533 res.status === 200
1534 ? Effect.flatMap(res.text, (responseBody) =>
1535 Effect.try({
1536 try: () => JSON.parse(responseBody) as T,
1537 catch: () => new Error(`non-json body: ${responseBody}`),
1538 }),
1539 )
1540 : Effect.fail(new Error(`Worker not ready: ${res.status}`)),
1541 ),
1542 Effect.retry({
1543 // Capped interval, ~90s total budget (workers.dev / DO propagation).
1544 schedule: Schedule.min([
1545 Schedule.exponential("500 millis"),
1546 Schedule.spaced("2 seconds"),
1547 ]),
1548 times: 45,
1549 }),
1550 );
1551 });
1552
1553// Assert that the site's *current* client bundle contains `marker`.
1554//
1555// The bundle filename is content-addressed, so an env-only redeploy changes
1556// the bundle URL referenced by `index.html`. A PoP can keep serving the
1557// previous deployment's `index.html` for a while after the version flip
1558// (cache-busting query params don't help when the whole deployment is stale
1559// at that edge) — so discovering the bundle URL *once* and then polling that
1560// asset latches onto the old, immutable bundle and times out waiting for a
1561// marker that will never appear there. Instead, re-discover the bundle URL
1562// from `index.html` on every attempt so the assertion converges as soon as
1563// the new deployment propagates.
1564const expectBundleContains = (
1565 siteUrl: string,
1566 marker: string,
1567 options: { label?: string } = {},
1568) =>
1569 Effect.gen(function* () {
1570 const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient);
1571 yield* Effect.gen(function* () {
1572 // Cache-bust the index fetch — the unique query string defeats the
1573 // CDN cache key for caches that respect it.
1574 const res = yield* client.get(`${siteUrl}/`, {
1575 urlParams: { __alchemy_cb: String(Date.now()) },
1576 headers: { "cache-control": "no-cache", pragma: "no-cache" },
1577 });
1578 const html = yield* res.text;
1579 const match = html.match(
1580 /<script[^>]+src="(\/assets\/[^"]+\.js)"[^>]*>/i,
1581 );
1582 if (!match) {
1583 // Fresh deploys can briefly return Cloudflare's "There is
1584 // nothing here yet" HTML page instead of the SPA index — retry.
1585 return yield* Effect.fail(
1586 new Error(
1587 `Could not find /assets/*.js script tag in HTML: ${html.slice(0, 200)}`,
1588 ),
1589 );
1590 }
1591 const bundleRes = yield* client.get(`${siteUrl}${match[1]}`, {
1592 urlParams: { __alchemy_cb: String(Date.now()) },
1593 headers: { "cache-control": "no-cache", pragma: "no-cache" },
1594 });
1595 const bundle = yield* bundleRes.text;
1596 if (!bundle.includes(marker)) {
1597 return yield* Effect.fail(
1598 new Error(
1599 `bundle ${match[1]} does not (yet) contain marker "${marker}"`,
1600 ),
1601 );
1602 }
1603 }).pipe(
1604 Effect.retry({
1605 // ~2 minutes total: capped exponential sampling through edge
1606 // propagation of both the fresh index.html and the new asset.
1607 schedule: Schedule.max([
1608 Schedule.min([
1609 Schedule.exponential("500 millis", 1.5),
1610 Schedule.spaced("5 seconds"),
1611 ]),
1612 Schedule.recurs(30),
1613 ]),
1614 }),
1615 Effect.tapError((error) =>
1616 Effect.logError(
1617 `expectBundleContains(${options.label ?? marker}) failed`,
1618 error,
1619 ),
1620 ),
1621 );
1622 });
1623
1624const viteProps = (rootDir: string, memoInclude: string[]) => ({
1625 rootDir,
1626 workersDev: true,
1627 compatibility: {
1628 date: "2024-09-23",
1629 flags: ["nodejs_compat"],
1630 },
1631 memo: { include: memoInclude },
1632});
1633
1634const htmlPage = (marker: string) => `<!doctype html>
1635<html lang="en">
1636 <head>
1637 <meta charset="UTF-8" />
1638 <title>${marker}</title>
1639 </head>
1640 <body>
1641 <div id="app">${marker}</div>
1642 <script type="module" src="/src/main.ts"></script>
1643 </body>
1644</html>
1645`;
1646
1647const tanstackIndexRouteSource = (marker: string) => `
1648/** @jsxImportSource react */
1649import { createFileRoute } from "@tanstack/react-router";
1650
1651const marker = ${JSON.stringify(marker)};
1652
1653export const Route = createFileRoute("/")({
1654 component: Home,
1655});
1656
1657function Home() {
1658 return <main>{marker}</main>;
1659}
1660`;
1661
1662const waitForBucketToBeDeleted = Effect.fn(function* (
1663 bucketName: string,
1664 accountId: string,
1665) {
1666 // Dev-mode buckets are purely virtual (`dev:`-prefixed identity) — there
1667 // is no cloud bucket to wait on, and the real API rejects the name.
1668 if (isLocalId(bucketName)) return;
1669 yield* r2
1670 .getBucket({
1671 accountId,
1672 bucketName,
1673 })
1674 .pipe(
1675 Effect.flatMap(() => Effect.fail(new BucketStillExists())),
1676 Effect.retry({
1677 while: (error): error is BucketStillExists =>
1678 error instanceof BucketStillExists,
1679 schedule: Schedule.max([
1680 Schedule.min([
1681 Schedule.exponential("200 millis"),
1682 Schedule.spaced("2 seconds"),
1683 ]),
1684 Schedule.recurs(20),
1685 ]),
1686 }),
1687 Effect.catchTag("NoSuchBucket", () => Effect.void),
1688 );
1689});
1690
1691class BucketStillExists extends Data.TaggedError("BucketStillExists") {}