1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import * as Test from "@/Test/Alchemy";
4import * as kv from "@distilled.cloud/cloudflare/kv";
5import { describe, expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Option from "effect/Option";
10import * as Path from "effect/Path";
11import { MinimumLogLevel } from "effect/References";
12import * as Schedule from "effect/Schedule";
13import * as Stream from "effect/Stream";
14import * as pathe from "pathe";
15import { cloneFixture } from "../Utils/Fixture.ts";
16import { expectUrlContains, expectUrlRedirect } from "../Utils/Http.ts";
17import {
18 expectWorkerExists,
19 waitForWorkerToBeDeleted,
20} from "../Utils/Worker.ts";
21
22const { test } = Test.make({ providers: Cloudflare.providers() });
23
24const logLevel = Effect.provideService(
25 MinimumLogLevel,
26 process.env.DEBUG ? "Debug" : "Info",
27);
28
29const fixtureDir = pathe.resolve(import.meta.dirname, "fixtures/astro-app");
30const staticFixtureDir = pathe.resolve(
31 import.meta.dirname,
32 "fixtures/astro-static-app",
33);
34
35// Keep the temp clone under the alchemy package so the project root stays
36// within the workspace (same constraint as the Vite tests) and so the
37// fixture's `astro` dependency resolves by walking up to
38// `packages/alchemy/node_modules`.
39const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
40
41class SessionFetchFailed extends Data.TaggedError("SessionFetchFailed")<{
42 url: string;
43 message: string;
44}> {}
45
46/**
47 * Cookie-aware fetch for the session round-trip: returns the body plus
48 * the `astro-session` cookie pair from `set-cookie` (if any).
49 */
50const fetchSession = (url: string, cookie?: string) =>
51 Effect.tryPromise({
52 try: async (signal) => {
53 const u = new URL(url);
54 u.searchParams.set("__alchemy_cb", String(Date.now()));
55 const res = await fetch(u, {
56 signal,
57 cache: "no-store",
58 headers: {
59 "cache-control": "no-cache",
60 accept: "*/*",
61 ...(cookie ? { cookie } : {}),
62 },
63 });
64 const body = await res.text();
65 const sessionCookie = res.headers
66 .get("set-cookie")
67 ?.match(/astro-session=[^;,\s]+/)?.[0];
68 return { status: res.status, body, sessionCookie };
69 },
70 catch: (e) =>
71 new SessionFetchFailed({
72 url,
73 message: e instanceof Error ? e.message : String(e),
74 }),
75 });
76
77class AstroResponseMismatch extends Data.TaggedError("AstroResponseMismatch")<{
78 url: string;
79 detail: string;
80}> {
81 override get message() {
82 return `${this.url} :: ${this.detail}`;
83 }
84}
85
86const responseRetry = Effect.retry({
87 schedule: Schedule.min([
88 Schedule.exponential("1 second", 1.5),
89 Schedule.spaced("8 seconds"),
90 ]),
91 times: 8,
92});
93
94/**
95 * Fetch the middleware-backed `/locals` page and assert the middleware ran
96 * in the SAME request that rendered the page: the `x-middleware: hit`
97 * header is present and the body renders the exact per-request id the
98 * middleware mirrored onto `x-request-id`.
99 */
100const expectMiddlewareLocals = (url: string) =>
101 Effect.tryPromise({
102 try: async (signal) => {
103 const u = new URL(url);
104 u.searchParams.set("__alchemy_cb", String(Date.now()));
105 const res = await fetch(u, {
106 signal,
107 cache: "no-store",
108 headers: { "cache-control": "no-cache", accept: "*/*" },
109 });
110 return {
111 status: res.status,
112 body: await res.text(),
113 middleware: res.headers.get("x-middleware"),
114 requestId: res.headers.get("x-request-id"),
115 };
116 },
117 catch: (e) =>
118 new AstroResponseMismatch({
119 url,
120 detail: e instanceof Error ? e.message : String(e),
121 }),
122 }).pipe(
123 Effect.filterOrFail(
124 (r) =>
125 r.status === 200 &&
126 r.middleware === "hit" &&
127 r.requestId !== null &&
128 r.body.includes(`locals-request-id=${r.requestId}`),
129 (r) =>
130 new AstroResponseMismatch({
131 url,
132 detail: `${r.status} x-middleware=${r.middleware} x-request-id=${r.requestId} body=${r.body.slice(0, 200)}`,
133 }),
134 ),
135 responseRetry,
136 );
137
138/**
139 * Fetch a hashed asset and assert it serves 200 with the immutable
140 * Cache-Control from the adapter-emitted `_headers`. Without the
141 * provider folding `_headers` into the PUT asset config the asset
142 * serves Cloudflare's default `public, max-age=0, must-revalidate`.
143 */
144const expectImmutableAsset = (assetUrl: string) =>
145 Effect.tryPromise({
146 try: async (signal) => {
147 const u = new URL(assetUrl);
148 u.searchParams.set("__alchemy_cb", String(Date.now()));
149 const res = await fetch(u, {
150 signal,
151 cache: "no-store",
152 headers: { "cache-control": "no-cache", accept: "*/*" },
153 });
154 await res.arrayBuffer();
155 return {
156 status: res.status,
157 cacheControl: res.headers.get("cache-control"),
158 };
159 },
160 catch: (e) =>
161 new AstroResponseMismatch({
162 url: assetUrl,
163 detail: e instanceof Error ? e.message : String(e),
164 }),
165 }).pipe(
166 Effect.filterOrFail(
167 (r) =>
168 r.status === 200 &&
169 r.cacheControl === "public, max-age=31536000, immutable",
170 (r) =>
171 new AstroResponseMismatch({
172 url: assetUrl,
173 detail: `${r.status} cache-control: ${r.cacheControl}`,
174 }),
175 ),
176 responseRetry,
177 );
178
179/** The exact payload `src/pages/api/binary.ts` serves. */
180const BINARY_BYTES = [
181 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01, 0x02, 0x03,
182];
183
184/**
185 * Fetch the binary endpoint and assert the exact bytes and content type
186 * survive the round trip through the Worker.
187 */
188const expectBinaryEndpoint = (url: string) =>
189 Effect.tryPromise({
190 try: async (signal) => {
191 const u = new URL(url);
192 u.searchParams.set("__alchemy_cb", String(Date.now()));
193 const res = await fetch(u, {
194 signal,
195 cache: "no-store",
196 headers: { "cache-control": "no-cache", accept: "*/*" },
197 });
198 return {
199 status: res.status,
200 contentType: res.headers.get("content-type"),
201 bytes: [...new Uint8Array(await res.arrayBuffer())],
202 };
203 },
204 catch: (e) =>
205 new AstroResponseMismatch({
206 url,
207 detail: e instanceof Error ? e.message : String(e),
208 }),
209 }).pipe(
210 Effect.filterOrFail(
211 (r) =>
212 r.status === 200 &&
213 r.contentType === "application/octet-stream" &&
214 r.bytes.length === BINARY_BYTES.length &&
215 r.bytes.every((b, i) => b === BINARY_BYTES[i]),
216 (r) =>
217 new AstroResponseMismatch({
218 url,
219 detail: `${r.status} ${r.contentType} bytes=[${r.bytes.join(",")}]`,
220 }),
221 ),
222 responseRetry,
223 );
224
225/**
226 * POST the urlencoded feedback form and assert the server-rendered echo —
227 * pins that POSTs reach the Worker through the asset router.
228 */
229const expectFormPostEcho = (url: string, message: string) =>
230 Effect.tryPromise({
231 try: async (signal) => {
232 const res = await fetch(url, {
233 signal,
234 method: "POST",
235 cache: "no-store",
236 body: new URLSearchParams({ message }),
237 headers: { "cache-control": "no-cache", accept: "*/*" },
238 });
239 return { status: res.status, body: await res.text() };
240 },
241 catch: (e) =>
242 new AstroResponseMismatch({
243 url,
244 detail: e instanceof Error ? e.message : String(e),
245 }),
246 }).pipe(
247 Effect.filterOrFail(
248 (r) => r.status === 200 && r.body.includes(`received: ${message}`),
249 (r) =>
250 new AstroResponseMismatch({
251 url,
252 detail: `${r.status} ${r.body.slice(0, 240)}`,
253 }),
254 ),
255 responseRetry,
256 );
257
258/**
259 * Round-trip a value through the fixture's `/api/kv` route (backed by the
260 * user-declared `SITE_KV` binding): PUT then GET until the read observes
261 * the write.
262 */
263const kvRoundTrip = (base: string, key: string, value: string) =>
264 Effect.gen(function* () {
265 yield* Effect.tryPromise({
266 try: async (signal) => {
267 const res = await fetch(
268 `${base}/api/kv?key=${encodeURIComponent(key)}&value=${encodeURIComponent(value)}`,
269 { signal, method: "PUT", cache: "no-store" },
270 );
271 return { status: res.status, body: await res.text() };
272 },
273 catch: (e) =>
274 new AstroResponseMismatch({
275 url: `${base}/api/kv`,
276 detail: e instanceof Error ? e.message : String(e),
277 }),
278 }).pipe(
279 Effect.filterOrFail(
280 (r) => r.status === 200,
281 (r) =>
282 new AstroResponseMismatch({
283 url: `${base}/api/kv`,
284 detail: `PUT ${r.status} ${r.body.slice(0, 240)}`,
285 }),
286 ),
287 responseRetry,
288 );
289
290 // Read back through the binding, retrying through KV read lag.
291 yield* Effect.tryPromise({
292 try: async (signal) => {
293 const res = await fetch(
294 `${base}/api/kv?key=${encodeURIComponent(key)}&__alchemy_cb=${Date.now()}`,
295 { signal, cache: "no-store" },
296 );
297 return { status: res.status, body: await res.text() };
298 },
299 catch: (e) =>
300 new AstroResponseMismatch({
301 url: `${base}/api/kv`,
302 detail: e instanceof Error ? e.message : String(e),
303 }),
304 }).pipe(
305 Effect.filterOrFail(
306 (r) => r.status === 200 && r.body.includes(`"value":"${value}"`),
307 (r) =>
308 new AstroResponseMismatch({
309 url: `${base}/api/kv`,
310 detail: `GET ${r.status} ${r.body.slice(0, 240)}`,
311 }),
312 ),
313 responseRetry,
314 );
315 });
316
317class NamespaceStillExists extends Data.TaggedError("NamespaceStillExists") {}
318
319const waitForNamespaceToBeDeleted = Effect.fn(function* (
320 namespaceId: string,
321 accountId: string,
322) {
323 yield* kv.getNamespace({ accountId, namespaceId }).pipe(
324 Effect.flatMap(() => Effect.fail(new NamespaceStillExists())),
325 Effect.retry({
326 while: (e): e is NamespaceStillExists =>
327 e instanceof NamespaceStillExists,
328 schedule: Schedule.min([
329 Schedule.exponential(250),
330 Schedule.spaced("2 seconds"),
331 ]),
332 times: 10,
333 }),
334 Effect.catchTag("NamespaceNotFound", () => Effect.void),
335 );
336});
337
338// Tests are independent (per-test scratch stacks, private fixture clones),
339// so run them concurrently; suites are sequential by default.
340describe.concurrent(
341 "Astro",
342 {
343 tags: [
344 "provider:cloudflare",
345 "provider:cloudflare:website",
346 "provider:cloudflare:worker",
347 "live",
348 ],
349 },
350 () => {
351 test.provider(
352 "Astro: SSR + env binding + prerender + static assets deploy; unchanged sources memo-skip the rebuild",
353 (stack) =>
354 Effect.gen(function* () {
355 const { accountId } = yield* yield* CloudflareEnvironment;
356 const fs = yield* FileSystem.FileSystem;
357 const path = yield* Path.Path;
358
359 yield* stack.destroy();
360
361 const rootDir = yield* cloneFixture(fixtureDir, {
362 prefix: "alchemy-astro-",
363 tempRoot,
364 entries: ["astro.config.mjs", "package.json", "public", "src"],
365 });
366
367 // Restrict the input memo to fixture sources so the test isn't
368 // re-hashing the whole monorepo on every deploy. `workspaces: []`
369 // pins the hash to the fixture alone: auto-detection would fold in
370 // workspace-linked integration packages, whose trees can change
371 // between deploys (concurrent development in this repo), breaking
372 // the unchanged-rebuild memo assertion below. Workspace-aware
373 // memoization has its own dedicated test in Vite.test.ts.
374 const memo = {
375 include: [
376 "src/**",
377 "public/**",
378 "package.json",
379 "astro.config.mjs",
380 ],
381 workspaces: [],
382 };
383
384 // A random value is fine here — it is binding data, not a resource
385 // name — and it stays constant across this test's deploys so the
386 // metadata hash cannot mask a broken input-hash memo.
387 const marker = `astro-marker-${Date.now()}`;
388
389 const deploy = () =>
390 stack.deploy(
391 Effect.gen(function* () {
392 // A REAL user-declared KV namespace bound through `env` —
393 // distinct from the auto-provisioned session namespace. The
394 // fixture's `/api/kv` route reads/writes it via the runtime env.
395 const siteKv = yield* Cloudflare.KV.Namespace("SiteKV");
396 const site = yield* Cloudflare.Website.Astro("AstroSite", {
397 rootDir,
398 workersDev: { enabled: true, previewsEnabled: true },
399 // `nodejs_compat` deliberately omitted — the resource
400 // auto-injects it, and this deploy proves that path.
401 compatibility: { date: "2026-03-10" },
402 memo,
403 env: { TEST_MARKER: marker, SITE_KV: siteKv },
404 assets: {
405 htmlHandling: "auto-trailing-slash",
406 notFoundHandling: "none",
407 },
408 });
409 // Resource creation dedupes by logical id, so this returns the
410 // session namespace the Astro resource auto-provisioned — a
411 // handle for the out-of-band lifecycle assertions below.
412 const sessions =
413 yield* Cloudflare.KV.Namespace("AstroSiteSession");
414 return { site, sessions, siteKv };
415 }),
416 );
417
418 // ── deploy 1: build + serve ────────────────────────────────────────
419 const { site: site1, sessions, siteKv } = yield* deploy();
420 expect(site1.url).toBeDefined();
421 expect(site1.hash?.input).toBeDefined();
422 yield* expectWorkerExists(site1.workerName, accountId);
423
424 // SSR page rendered in the Worker, reading the env binding.
425 yield* expectUrlContains(`${site1.url!}/`, marker, {
426 timeout: "120 seconds",
427 label: "SSR page renders env binding",
428 });
429 // Prerendered page served from static assets. Keep the body — the
430 // hashed-asset assertion below extracts the `/_astro/*.css` href.
431 const aboutBody = yield* expectUrlContains(
432 `${site1.url!}/about/`,
433 "prerendered-page",
434 {
435 timeout: "60 seconds",
436 label: "prerendered page",
437 },
438 );
439 // Plain static asset from public/.
440 yield* expectUrlContains(
441 `${site1.url!}/static.txt`,
442 "astro-static-asset",
443 {
444 timeout: "60 seconds",
445 label: "static asset",
446 },
447 );
448
449 // ── sessions: the SESSION KV namespace is auto-provisioned and
450 // `Astro.session` round-trips through it ───────────────────────────
451 expect(sessions.namespaceId).toBeDefined();
452 const actualSessions = yield* kv.getNamespace({
453 accountId,
454 namespaceId: sessions.namespaceId,
455 });
456 expect(actualSessions.id).toEqual(sessions.namespaceId);
457
458 // Wait for the route to serve before the cookie round-trip.
459 yield* expectUrlContains(`${site1.url!}/session`, "session-count=", {
460 timeout: "60 seconds",
461 label: "session page",
462 });
463
464 // A single edge request can still 404 right after the readiness
465 // probe above succeeded (workers.dev colo inconsistency) — retry
466 // through it, bounded, before asserting on the body.
467 const first = yield* fetchSession(`${site1.url!}/session`).pipe(
468 Effect.filterOrFail(
469 (res) => res.status === 200,
470 (res) =>
471 new SessionFetchFailed({
472 url: `${site1.url!}/session`,
473 message: `expected 200, got ${res.status}: ${res.body.slice(0, 240)}`,
474 }),
475 ),
476 Effect.retry({
477 schedule: Schedule.exponential("1 second", 1.5),
478 times: 8,
479 }),
480 );
481 expect(first.status).toBe(200);
482 expect(first.body).toContain("session-count=1");
483 expect(first.sessionCookie).toBeDefined();
484
485 // Replaying the session cookie must observe the previous request's
486 // KV write. Retry through KV's (brief, same-colo) read lag.
487 const second = yield* fetchSession(
488 `${site1.url!}/session`,
489 first.sessionCookie,
490 ).pipe(
491 Effect.filterOrFail(
492 (res) => res.body.includes("session-count=2"),
493 (res) =>
494 new SessionFetchFailed({
495 url: `${site1.url!}/session`,
496 message: `expected session-count=2, got: ${res.body.slice(0, 240)}`,
497 }),
498 ),
499 Effect.retry({
500 schedule: Schedule.exponential("1 second", 1.5),
501 times: 8,
502 }),
503 );
504 expect(second.body).toContain("session-count=2");
505
506 // ── middleware: locals + response-header mutation ──────────────────
507 yield* expectMiddlewareLocals(`${site1.url!}/locals`);
508
509 // ── config redirects flow through `_redirects` to the asset layer ──
510 yield* expectUrlRedirect(`${site1.url!}/old-about`, "/about/", {
511 status: 301,
512 timeout: "60 seconds",
513 label: "config redirect via _redirects",
514 });
515
516 // ── binary endpoint returns the exact bytes ────────────────────────
517 yield* expectBinaryEndpoint(`${site1.url!}/api/binary`);
518
519 // ── urlencoded form POST reaches the Worker through the asset
520 // router and re-renders with the echoed message ─────────────────────
521 yield* expectFormPostEcho(`${site1.url!}/feedback`, marker);
522
523 // ── user-declared KV binding: round-trip through the runtime env,
524 // then verify out-of-band that the write landed in the REAL
525 // namespace ────────────────────────────────────────────────────────
526 expect(siteKv.namespaceId).toBeDefined();
527 yield* kvRoundTrip(site1.url!, "astro-live-key", marker);
528 const observed = yield* kv
529 .getNamespaceValue({
530 accountId,
531 namespaceId: siteKv.namespaceId,
532 keyName: "astro-live-key",
533 })
534 .pipe(
535 Effect.flatMap((res) =>
536 Effect.tryPromise(() =>
537 new Response(
538 Stream.toReadableStream(res.body) as BodyInit,
539 ).text(),
540 ),
541 ),
542 Effect.retry({
543 schedule: Schedule.exponential("1 second", 1.5),
544 times: 8,
545 }),
546 );
547 expect(observed).toBe(marker);
548
549 // ── hashed `/_astro/*` asset serves with the immutable Cache-Control
550 // from the adapter-emitted `_headers` — pins the WorkerProvider
551 // folding a source build's `_headers`/`_redirects` into the PUT
552 // asset config (source providers hash the files but don't pre-merge
553 // them into `config`; only alchemy's own `readAssets` does). Without
554 // that merge the asset serves Cloudflare's default
555 // `public, max-age=0, must-revalidate`. ───────────────────────────
556 const assetHref = aboutBody.match(/\/_astro\/[^"']+\.css/)?.[0];
557 expect(assetHref).toBeDefined();
558 const assetUrl = `${site1.url!}${assetHref!}`;
559 yield* expectImmutableAsset(assetUrl);
560
561 // ── deploy 2: nothing changed ⇒ memo hit (no rebuild) ──────────────
562 const { site: site2 } = yield* deploy();
563 expect(site2.hash?.input).toEqual(site1.hash?.input);
564 expect(site2.hash?.bundle).toEqual(site1.hash?.bundle);
565 expect(site2.hash?.assets).toEqual(site1.hash?.assets);
566
567 // The no-op redeploy takes the provider's hash-matched KEEP path,
568 // which rebuilds the asset config wholesale (no upload jwt). A
569 // regression there would silently wipe the `_headers`/`_redirects`
570 // rules on every no-op deploy — re-assert both still serve.
571 yield* expectImmutableAsset(assetUrl);
572 yield* expectUrlRedirect(`${site1.url!}/old-about`, "/about/", {
573 status: 301,
574 timeout: "60 seconds",
575 label: "redirect after no-op keep-assets deploy",
576 });
577
578 // ── deploy 3: edit a page ⇒ memo busts, new content serves ─────────
579 const indexPath = path.join(rootDir, "src/pages/index.astro");
580 const source = yield* fs.readFileString(indexPath);
581 yield* fs.writeFileString(
582 indexPath,
583 source.replace("Astro Fixture", "Astro Fixture Edited"),
584 );
585
586 const { site: site3 } = yield* deploy();
587 expect(site3.hash?.input).toBeDefined();
588 expect(site3.hash?.input).not.toEqual(site1.hash?.input);
589 yield* expectUrlContains(`${site3.url!}/`, "Astro Fixture Edited", {
590 timeout: "60 seconds",
591 label: "edited SSR page",
592 });
593
594 yield* stack.destroy();
595 yield* waitForWorkerToBeDeleted(site1.workerName, accountId);
596
597 // Destroy must also clean up the auto-provisioned session namespace
598 // and the user-declared SITE_KV namespace.
599 yield* waitForNamespaceToBeDeleted(sessions.namespaceId, accountId);
600 yield* waitForNamespaceToBeDeleted(siteKv.namespaceId, accountId);
601 }).pipe(logLevel),
602 { tags: ["provider:cloudflare:kv"], timeout: 420_000 },
603 );
604
605 class NotFoundPageMismatch extends Data.TaggedError(
606 "NotFoundPageMismatch",
607 )<{
608 url: string;
609 actual: string;
610 }> {}
611
612 /**
613 * Assert `url` answers a REAL `404` whose body contains `marker` — the
614 * built `404.html` served by the asset layer (`notFoundHandling:
615 * "404-page"`), not a worker-rendered page and not Cloudflare's
616 * placeholder. Retries through edge propagation like `expectUrlContains`
617 * (which can't be used here: it requires `res.ok`).
618 */
619 const expectNotFoundPage = (url: string, marker: string) =>
620 Effect.tryPromise({
621 try: async (signal) => {
622 const u = new URL(url);
623 u.searchParams.set("__alchemy_cb", String(Date.now()));
624 const res = await fetch(u, {
625 signal,
626 cache: "no-store",
627 headers: { "cache-control": "no-cache", accept: "*/*" },
628 });
629 const body = await res.text();
630 return { status: res.status, body };
631 },
632 catch: (e) =>
633 new NotFoundPageMismatch({
634 url,
635 actual: e instanceof Error ? e.message : String(e),
636 }),
637 }).pipe(
638 Effect.filterOrFail(
639 (res) => res.status === 404 && res.body.includes(marker),
640 (res) =>
641 new NotFoundPageMismatch({
642 url,
643 actual: `${res.status} ${res.body.slice(0, 240)}`,
644 }),
645 ),
646 Effect.retry({
647 schedule: Schedule.max([
648 Schedule.min([
649 Schedule.exponential("750 millis", 1.5),
650 Schedule.spaced("8 seconds"),
651 ]),
652 Schedule.recurs(15),
653 ]),
654 }),
655 );
656
657 // ─────────────────────────────────────────────────────────────────────
658 // Assets-only deploys (seam: SourceBuildOutput.bundle === undefined)
659 //
660 // A declared `output: "static"` project prerenders every page,
661 // so the astro source's build produces NO server modules — the source
662 // path must flow `bundle: undefined` into the WorkerProvider's
663 // assets-only mode: the script PUT carries no modules and no main_module,
664 // and Cloudflare's asset layer answers every request (including the built
665 // 404.html via `errorPage: "404.html"`). Session auto-provisioning
666 // is skipped — no Worker code could ever read the namespace.
667 // ─────────────────────────────────────────────────────────────────────
668
669 test.provider(
670 "Astro: output 'static' deploys assets-only — no server bundle, 404.html from assets, memoized rebuilds",
671 (stack) =>
672 Effect.gen(function* () {
673 const { accountId } = yield* yield* CloudflareEnvironment;
674 const fs = yield* FileSystem.FileSystem;
675 const path = yield* Path.Path;
676
677 yield* stack.destroy();
678
679 const rootDir = yield* cloneFixture(staticFixtureDir, {
680 prefix: "alchemy-astro-static-",
681 tempRoot,
682 entries: ["package.json", "public", "src"],
683 });
684
685 // Same discipline as the SSR test above: pin the input hash to the
686 // fixture so concurrent development in this repo can't bust the
687 // unchanged-rebuild memo assertion.
688 const memo = {
689 include: ["src/**", "public/**", "package.json"],
690 workspaces: [],
691 };
692
693 const deploy = () =>
694 stack.deploy(
695 Effect.gen(function* () {
696 return yield* Cloudflare.Website.Astro("AstroStaticSite", {
697 rootDir,
698 workersDev: { enabled: true, previewsEnabled: true },
699 compatibility: { date: "2026-03-10" },
700 memo,
701 astro: { output: "static" },
702 assets: { notFoundHandling: "404-page" },
703 });
704 }),
705 );
706
707 // ── deploy 1: build + assets-only serve ────────────────────────────
708 const site1 = yield* deploy();
709 expect(site1.url).toBeDefined();
710 expect(site1.hash?.input).toBeDefined();
711 expect(site1.hash?.assets).toBeDefined();
712 // The contract under test: no server bundle was produced or
713 // uploaded — the bundle hash slot stays empty.
714 expect(site1.hash?.bundle).toBeUndefined();
715 yield* expectWorkerExists(site1.workerName, accountId);
716
717 // Every page serves from the asset layer.
718 yield* expectUrlContains(`${site1.url!}/`, "static-home", {
719 timeout: "120 seconds",
720 label: "static home page",
721 });
722 yield* expectUrlContains(`${site1.url!}/about/`, "static-about", {
723 timeout: "60 seconds",
724 label: "static about page",
725 });
726 yield* expectUrlContains(
727 `${site1.url!}/static.txt`,
728 "astro-static-public-asset",
729 {
730 timeout: "60 seconds",
731 label: "public asset",
732 },
733 );
734
735 // Unknown routes get the BUILT 404.html with a real 404 status —
736 // Cloudflare's asset layer applies `notFoundHandling` itself; no
737 // Worker script is involved.
738 yield* expectNotFoundPage(
739 `${site1.url!}/definitely-not-a-page`,
740 "static-404",
741 );
742
743 // Session auto-provisioning is skipped for declared-static sites:
744 // no KV namespace titled for this stack's session id may exist.
745 // (Physical titles embed the logical id verbatim:
746 // `{stack}-AstroStaticSiteSession-{stage}-{suffix}`.)
747 const sessionNamespace = yield* kv.listNamespaces
748 .items({ accountId })
749 .pipe(
750 Stream.filter((ns) =>
751 ns.title.includes("AstroStaticSiteSession"),
752 ),
753 Stream.runHead,
754 Effect.map(Option.getOrUndefined),
755 );
756 expect(sessionNamespace).toBeUndefined();
757
758 // ── deploy 2: nothing changed ⇒ memo hit, still assets-only ────────
759 const site2 = yield* deploy();
760 expect(site2.hash?.input).toEqual(site1.hash?.input);
761 expect(site2.hash?.assets).toEqual(site1.hash?.assets);
762 expect(site2.hash?.bundle).toBeUndefined();
763 expect(site2.url).toBe(site1.url);
764
765 // ── deploy 3: edit a page ⇒ memo busts, new content serves,
766 // and the deploy REMAINS assets-only ───────────────────────────────
767 const indexPath = path.join(rootDir, "src/pages/index.astro");
768 const source = yield* fs.readFileString(indexPath);
769 yield* fs.writeFileString(
770 indexPath,
771 source.replace("static-home", "static-home-v2"),
772 );
773
774 const site3 = yield* deploy();
775 expect(site3.hash?.input).toBeDefined();
776 expect(site3.hash?.input).not.toEqual(site1.hash?.input);
777 expect(site3.hash?.bundle).toBeUndefined();
778 yield* expectUrlContains(`${site3.url!}/`, "static-home-v2", {
779 timeout: "60 seconds",
780 label: "edited static home page",
781 });
782
783 yield* stack.destroy();
784 yield* waitForWorkerToBeDeleted(site1.workerName, accountId);
785 }).pipe(logLevel),
786 { tags: ["provider:cloudflare:kv"], timeout: 360_000 },
787 );
788
789 // ─────────────────────────────────────────────────────────────────────
790 // SPA fallback: `assets.notFoundHandling: "single-page-application"`
791 //
792 // A client-routed static build: every real page is a prerendered asset,
793 // and a hard GET to any UNREGISTERED route must serve the app shell
794 // (`/index.html`) with a 200 so the client router can boot. The built
795 // `404.html` must NOT serve — SPA handling supersedes the 404 page.
796 // ─────────────────────────────────────────────────────────────────────
797
798 test.provider(
799 "Astro: SPA not-found handling serves the app shell for deep links",
800 (stack) =>
801 Effect.gen(function* () {
802 const { accountId } = yield* yield* CloudflareEnvironment;
803
804 yield* stack.destroy();
805
806 const rootDir = yield* cloneFixture(staticFixtureDir, {
807 prefix: "alchemy-astro-spa-",
808 tempRoot,
809 entries: ["package.json", "public", "src"],
810 });
811
812 const site = yield* stack.deploy(
813 Effect.gen(function* () {
814 return yield* Cloudflare.Website.Astro("AstroSpaSite", {
815 rootDir,
816 workersDev: { enabled: true, previewsEnabled: true },
817 compatibility: { date: "2026-03-10" },
818 memo: {
819 include: ["src/**", "public/**", "package.json"],
820 workspaces: [],
821 },
822 astro: { output: "static" },
823 assets: { notFoundHandling: "single-page-application" },
824 });
825 }),
826 );
827
828 expect(site.url).toBeDefined();
829 // Fully-static SPA deploy: assets only, no server bundle.
830 expect(site.hash?.bundle).toBeUndefined();
831 yield* expectWorkerExists(site.workerName, accountId);
832
833 // The app shell itself serves at `/`.
834 yield* expectUrlContains(`${site.url!}/`, "static-home", {
835 timeout: "120 seconds",
836 label: "SPA shell at /",
837 });
838
839 // (a) A hard GET to a route that was never registered serves the
840 // shell (`index.html`) with a 200 — `expectUrlContains` requires
841 // `res.ok`, so a 404 status can't satisfy this. The built
842 // `404.html` must NOT leak through.
843 const deepLinkBody = yield* expectUrlContains(
844 `${site.url!}/spa/definitely/not/a/route`,
845 "static-home",
846 {
847 timeout: "60 seconds",
848 label: "deep link serves SPA shell",
849 },
850 );
851 expect(deepLinkBody).not.toContain("static-404");
852
853 // (b) A real prerendered page still serves its own content, not
854 // the shell.
855 const aboutBody = yield* expectUrlContains(
856 `${site.url!}/about/`,
857 "static-about",
858 {
859 timeout: "60 seconds",
860 label: "real page still serves",
861 },
862 );
863 expect(aboutBody).not.toContain("static-home");
864
865 // (c) A real static asset still serves normally.
866 yield* expectUrlContains(
867 `${site.url!}/static.txt`,
868 "astro-static-public-asset",
869 {
870 timeout: "60 seconds",
871 label: "static asset with SPA handling",
872 },
873 );
874
875 yield* stack.destroy();
876 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
877 }).pipe(logLevel),
878 { timeout: 360_000 },
879 );
880 },
881);