1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import * as Command from "@/Command/index.ts";
4import * as Output from "@/Output.ts";
5import type * as Plan from "@/Plan.ts";
6import { Stack } from "@/Stack";
7import { encodeState, type ResourceState, reviveState, State } from "@/State";
8import * as Test from "@/Test/Alchemy";
9import { describe, expect } from "alchemy-test";
10import * as Config from "effect/Config";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as Path from "effect/Path";
15import { MinimumLogLevel } from "effect/References";
16import * as pathe from "pathe";
17import { cloneFixture } from "../Utils/Fixture.ts";
18import { expectUrlContains, expectUrlHeader } from "../Utils/Http.ts";
19import {
20 expectWorkerExists,
21 waitForWorkerToBeDeleted,
22} from "../Utils/Worker.ts";
23
24const { test } = Test.make({ providers: Cloudflare.providers() });
25
26const logLevel = Effect.provideService(
27 MinimumLogLevel,
28 process.env.DEBUG ? "Debug" : "Info",
29);
30
31const fixtureDir = pathe.resolve(import.meta.dirname, "staticsite-fixture");
32const workerEntry = pathe.resolve(import.meta.dirname, "fixtures/worker.ts");
33
34// Read back through `Config.String` by the #796 test below. Set at module
35// load (collection time): `ConfigProvider.fromEnv` snapshots `process.env`
36// when the test harness constructs the provider — BEFORE a test body runs —
37// so setting this inside the test only ever satisfied the NEXT attempt
38// (the failure was masked by the runner's default retry).
39process.env.STATICSITE_ENV_TEST = "from-config";
40
41describe.concurrent(
42 "StaticSite",
43 { tags: ["provider:cloudflare", "provider:cloudflare:website", "live"] },
44 () => {
45 test.provider(
46 "StaticSite: editing a source file republishes the assets in a single deploy",
47 (stack) =>
48 Effect.gen(function* () {
49 const { accountId } = yield* yield* CloudflareEnvironment;
50 const fs = yield* FileSystem.FileSystem;
51 const path = yield* Path.Path;
52
53 yield* stack.destroy();
54
55 const cwd = yield* cloneFixture(fixtureDir, {
56 prefix: "alchemy-staticsite-fix-",
57 entries: ["src", "build.sh"],
58 });
59 const indexPath = path.join(cwd, "src", "index.html");
60
61 // ── deploy 1: initial publish ──────────────────────────────────────
62 const v1Marker = `staticsite-v1-${Date.now()}`;
63 yield* fs.writeFileString(indexPath, htmlPage(v1Marker));
64
65 const site1 = yield* stack.deploy(
66 Effect.gen(function* () {
67 return yield* Cloudflare.Website.StaticSite(
68 "FixSite",
69 staticSiteProps(cwd),
70 );
71 }),
72 );
73
74 expect(site1.url).toBeDefined();
75 expect(site1.hash?.assets).toBeDefined();
76 yield* expectWorkerExists(site1.workerName, accountId);
77 // End-to-end: the worker URL actually serves the v1 marker.
78 // Use a long timeout because workers.dev subdomains can take 60s+
79 // to propagate the very first time they're enabled.
80 yield* expectUrlContains(`${site1.url!}/index.html`, v1Marker, {
81 timeout: "120 seconds",
82 label: "deploy1 v1 marker",
83 });
84
85 // ── deploy 2: edit fixture, redeploy once ──────────────────────────
86 const v2Marker = `staticsite-v2-${Date.now()}`;
87 yield* fs.writeFileString(indexPath, htmlPage(v2Marker));
88
89 const site2 = yield* stack.deploy(
90 Effect.gen(function* () {
91 return yield* Cloudflare.Website.StaticSite(
92 "FixSite",
93 staticSiteProps(cwd),
94 );
95 }),
96 );
97
98 expect(site2.hash?.assets).toBeDefined();
99 expect(site2.hash?.assets).not.toEqual(site1.hash?.assets);
100
101 // The single-deploy guarantee: after one redeploy, the new
102 // marker is reachable over HTTP. Before the fix, this failure
103 // mode is what users were hitting — the worker version finalized
104 // pointing at the previous asset manifest because the initial
105 // Worker.update read dist mid-write.
106 yield* expectUrlContains(`${site2.url!}/index.html`, v2Marker, {
107 timeout: "60 seconds",
108 label: "deploy2 v2 marker",
109 });
110 // And the v1 marker should be gone — i.e. the new deploy fully
111 // replaced the previous content rather than coexisting with it.
112 yield* expectUrlAbsent(`${site2.url!}/index.html`, v1Marker, {
113 timeout: "30 seconds",
114 });
115
116 yield* stack.destroy();
117 yield* waitForWorkerToBeDeleted(site1.workerName, accountId);
118 }).pipe(logLevel),
119 { tags: ["provider:cloudflare:worker"], timeout: 360_000 },
120 );
121
122 test.provider(
123 "StaticSite: class form deploys and serves the built assets",
124 (stack) =>
125 Effect.gen(function* () {
126 const { accountId } = yield* yield* CloudflareEnvironment;
127 const fs = yield* FileSystem.FileSystem;
128 const path = yield* Path.Path;
129
130 yield* stack.destroy();
131
132 const cwd = yield* cloneFixture(fixtureDir, {
133 prefix: "alchemy-staticsite-class-",
134 entries: ["src", "build.sh"],
135 });
136 const indexPath = path.join(cwd, "src", "index.html");
137
138 const marker = `staticsite-class-${Date.now()}`;
139 yield* fs.writeFileString(indexPath, htmlPage(marker));
140
141 const site1 = yield* stack.deploy(
142 Effect.gen(function* () {
143 return yield* class FixSite extends Cloudflare.Website.StaticSite<FixSite>()(
144 "FixSite",
145 staticSiteProps(cwd),
146 ) {};
147 }),
148 );
149
150 expect(site1.url).toBeDefined();
151 expect(site1.hash?.assets).toBeDefined();
152 yield* expectWorkerExists(site1.workerName, accountId);
153 yield* expectUrlContains(`${site1.url!}/index.html`, marker, {
154 timeout: "120 seconds",
155 label: "class form marker",
156 });
157
158 yield* stack.destroy();
159 yield* waitForWorkerToBeDeleted(site1.workerName, accountId);
160 }).pipe(logLevel),
161 { tags: ["provider:cloudflare:worker"], timeout: 360_000 },
162 );
163
164 // ─────────────────────────────────────────────────────────────────────
165 // Path-relocation / cross-machine state behavior
166 //
167 // `StaticSite` builds via `Build.Command` and hands its `outdir` +
168 // content hash to `Worker` as `AssetsWithHash`. The hash is the only
169 // thing the diff/keepAssets logic should care about — the recorded
170 // `path` is *not* an input. These tests pin that down so that:
171 //
172 // 1. State produced on machine A (e.g. a CI runner) can be
173 // re-applied on machine B without `NotFound` failures from a
174 // stale `path` lurking in state.
175 // 2. A worker-only edit, with `src/` byte-identical, keeps the
176 // asset manifest in place instead of re-uploading.
177 // 3. A genuine source edit (already covered above) bumps the
178 // hash and ships new bytes — repeated here for symmetry.
179 // ─────────────────────────────────────────────────────────────────────
180
181 test.provider(
182 "StaticSite: relocating the project (and deleting the old one) preserves hash.assets",
183 (stack) =>
184 Effect.gen(function* () {
185 const { accountId } = yield* yield* CloudflareEnvironment;
186 const fs = yield* FileSystem.FileSystem;
187 const path = yield* Path.Path;
188
189 yield* stack.destroy();
190
191 // Include `.gitignore` so `Build.Command`'s default memo skips
192 // `dist/` between deploys; without it, the build output from
193 // deploy 1 would shift the input hash on deploy 2 and force an
194 // unnecessary rebuild that defeats the test.
195 const cwdA = yield* cloneFixture(fixtureDir, {
196 prefix: "alchemy-staticsite-relocate-a-",
197 entries: ["src", "build.sh", ".gitignore"],
198 });
199
200 // Pin a deterministic marker so both deploys hash to the same
201 // bytes regardless of timestamps.
202 const marker = `staticsite-relocate-${Date.now()}`;
203 yield* fs.writeFileString(
204 path.join(cwdA, "src", "index.html"),
205 htmlPage(marker),
206 );
207
208 const site1 = yield* stack.deploy(
209 Effect.gen(function* () {
210 return yield* Cloudflare.Website.StaticSite(
211 "RelocSite",
212 staticSiteProps(cwdA),
213 );
214 }),
215 );
216 expect(site1.hash?.assets).toBeDefined();
217 yield* expectUrlContains(`${site1.url!}/index.html`, marker, {
218 timeout: "120 seconds",
219 label: "deploy1 marker",
220 });
221
222 // Simulate the CI→local handoff: throw away the directory the
223 // first deploy ran in. Anything that still tries to readDir the
224 // recorded `outdir` will blow up here — the keepAssets path
225 // must not require it.
226 yield* fs.remove(cwdA, { recursive: true });
227
228 const cwdB = yield* cloneFixture(fixtureDir, {
229 prefix: "alchemy-staticsite-relocate-b-",
230 entries: ["src", "build.sh", ".gitignore"],
231 });
232 yield* fs.writeFileString(
233 path.join(cwdB, "src", "index.html"),
234 htmlPage(marker),
235 );
236
237 const site2 = yield* stack.deploy(
238 Effect.gen(function* () {
239 return yield* Cloudflare.Website.StaticSite(
240 "RelocSite",
241 staticSiteProps(cwdB),
242 );
243 }),
244 );
245
246 // The build still runs (Build.Command always re-runs its
247 // command), but the resulting content hash is identical, so
248 // Worker takes the keepAssets path and the recorded
249 // `hash.assets` is stable.
250 expect(site2.hash?.assets).toEqual(site1.hash?.assets);
251 // And the URL keeps serving — i.e. we didn't lose the asset
252 // binding in the process.
253 yield* expectUrlContains(`${site2.url!}/index.html`, marker, {
254 timeout: "60 seconds",
255 label: "deploy2 marker",
256 });
257
258 yield* stack.destroy();
259 yield* waitForWorkerToBeDeleted(site1.workerName, accountId);
260 }).pipe(logLevel),
261 { tags: ["provider:cloudflare:worker"], timeout: 360_000 },
262 );
263
264 test.provider(
265 "StaticSite: a bundle-only change keeps the asset manifest (hash.assets stable)",
266 (stack) =>
267 Effect.gen(function* () {
268 const { accountId } = yield* yield* CloudflareEnvironment;
269 const fs = yield* FileSystem.FileSystem;
270 const path = yield* Path.Path;
271
272 yield* stack.destroy();
273
274 // Include `.gitignore` so the memo (which falls back to gitignore
275 // when not explicitly configured) skips `dist/` between deploys —
276 // otherwise the second `hashDirectory` would observe the build
277 // output from deploy 1 and produce a different input hash.
278 const cwd = yield* cloneFixture(fixtureDir, {
279 prefix: "alchemy-staticsite-bundle-only-",
280 entries: ["src", "build.sh", ".gitignore"],
281 });
282 const marker = `staticsite-bundle-only-${Date.now()}`;
283 yield* fs.writeFileString(
284 path.join(cwd, "src", "index.html"),
285 htmlPage(marker),
286 );
287 // Use a temp worker entry so we can edit it between deploys to
288 // shift `hash.bundle` without touching `src/`.
289 const workerDir = yield* fs.makeTempDirectory({
290 prefix: "alchemy-staticsite-bundle-only-entry-",
291 });
292 const workerPath = path.join(workerDir, "worker.ts");
293 const writeWorker = (variant: string) =>
294 fs.writeFileString(
295 workerPath,
296 `export default {
297 fetch: async () => new Response(${JSON.stringify(`bundle-only ${variant}`)}),
298};
299`,
300 );
301
302 const deploy = () =>
303 stack.deploy(
304 Effect.gen(function* () {
305 return yield* Cloudflare.Website.StaticSite(
306 "BundleOnlyStaticSite",
307 {
308 ...staticSiteProps(cwd),
309 main: workerPath,
310 },
311 );
312 }),
313 );
314
315 yield* writeWorker("v1");
316 const v1 = yield* deploy();
317 expect(v1.hash?.assets).toBeDefined();
318 expect(v1.hash?.bundle).toBeDefined();
319 yield* expectUrlContains(`${v1.url!}/index.html`, marker, {
320 timeout: "120 seconds",
321 label: "v1 marker",
322 });
323
324 yield* writeWorker("v2");
325 const v2 = yield* deploy();
326 expect(v2.hash?.bundle).not.toEqual(v1.hash?.bundle);
327 expect(v2.hash?.assets).toEqual(v1.hash?.assets);
328 yield* expectUrlContains(`${v2.url!}/index.html`, marker, {
329 timeout: "60 seconds",
330 label: "v2 marker (assets unchanged)",
331 });
332
333 yield* stack.destroy();
334 yield* waitForWorkerToBeDeleted(v1.workerName, accountId);
335 }).pipe(logLevel),
336 { tags: ["provider:cloudflare:worker"], timeout: 360_000 },
337 );
338
339 test.provider(
340 "StaticSite: rebuilds when the build output is missing despite unchanged inputs",
341 (stack) =>
342 Effect.gen(function* () {
343 const { accountId } = yield* yield* CloudflareEnvironment;
344 const fs = yield* FileSystem.FileSystem;
345 const path = yield* Path.Path;
346
347 yield* stack.destroy();
348
349 const cwd = yield* cloneFixture(fixtureDir, {
350 prefix: "alchemy-staticsite-missing-output-",
351 entries: ["src", "build.sh", ".gitignore"],
352 });
353 const marker = `staticsite-missing-output-${Date.now()}`;
354 yield* fs.writeFileString(
355 path.join(cwd, "src", "index.html"),
356 htmlPage(marker),
357 );
358 const outdir = path.join(cwd, "dist");
359
360 const deploy = () =>
361 stack.deploy(
362 Effect.gen(function* () {
363 return yield* Cloudflare.Website.StaticSite(
364 "MissingOutputStaticSite",
365 staticSiteProps(cwd),
366 );
367 }),
368 );
369
370 const v1 = yield* deploy();
371 expect(v1.hash?.assets).toBeDefined();
372 expect(yield* fs.exists(outdir)).toBe(true);
373 yield* expectUrlContains(`${v1.url!}/index.html`, marker, {
374 timeout: "120 seconds",
375 label: "v1 marker",
376 });
377
378 // Blow away the build output without touching any inputs. The input
379 // hash is unchanged, so memoization alone would skip the rebuild — but
380 // the Build provider also detects the missing output and forces an
381 // update, otherwise the Worker would have no assets to publish.
382 yield* fs.remove(outdir, { recursive: true });
383 expect(yield* fs.exists(outdir)).toBe(false);
384
385 const v2 = yield* deploy();
386 // The build re-ran: the output directory is back and the asset hash is
387 // identical (same source content reproduced the same manifest).
388 expect(yield* fs.exists(outdir)).toBe(true);
389 expect(v2.hash?.assets).toEqual(v1.hash?.assets);
390 yield* expectUrlContains(`${v2.url!}/index.html`, marker, {
391 timeout: "60 seconds",
392 label: "v2 marker (output rebuilt)",
393 });
394
395 yield* stack.destroy();
396 yield* waitForWorkerToBeDeleted(v1.workerName, accountId);
397 }).pipe(logLevel),
398 { tags: ["provider:cloudflare:worker"], timeout: 360_000 },
399 );
400
401 // ─────────────────────────────────────────────────────────────────────
402 // #1056: the assets hash must be a string
403 //
404 // `Command.Build.hash` is a `{ input, output }` pair; `AssetsWithHash.hash`
405 // is a single `string` that the Worker compares with `!==` against the value
406 // it persisted. Handing the pair through made every redeploy dirty and
407 // re-uploaded the whole asset directory, because state round-trips through
408 // JSON and a freshly built object is never `===` its deserialized self.
409 // ─────────────────────────────────────────────────────────────────────
410
411 /**
412 * Round-trip every state row through JSON, exactly like a real state store.
413 *
414 * `test.provider`'s scratch store keeps rows in memory *by reference*, so an
415 * object-valued hash compares `===` against itself and this bug is invisible.
416 * `localState` (and R2, and the HTTP store) serialize on write and revive on
417 * read — that is what production plans diff against, so reproduce it here.
418 */
419 const roundTripState = Effect.gen(function* () {
420 const state = yield* yield* State;
421 const stk = yield* Stack;
422 const fqns = yield* state.list({ stack: stk.name, stage: stk.stage });
423 yield* Effect.forEach(fqns, (fqn) =>
424 Effect.gen(function* () {
425 const row = yield* state.get({
426 stack: stk.name,
427 stage: stk.stage,
428 fqn,
429 });
430 if (row === undefined) return;
431 yield* state.set({
432 stack: stk.name,
433 stage: stk.stage,
434 fqn,
435 value: JSON.parse(
436 JSON.stringify(encodeState(row)),
437 reviveState,
438 ) as ResourceState,
439 });
440 }),
441 );
442 });
443
444 const actionOf = (plan: Plan.Plan, fqn: string) =>
445 plan.resources[fqn]?.action;
446
447 test.provider(
448 "StaticSite: redeploying an unchanged site is a noop (#1056)",
449 (stack) =>
450 Effect.gen(function* () {
451 const { accountId } = yield* yield* CloudflareEnvironment;
452 const fs = yield* FileSystem.FileSystem;
453 const path = yield* Path.Path;
454
455 yield* stack.destroy();
456
457 // `.gitignore` keeps `dist/` out of the build's input hash, so the
458 // second pass sees byte-identical inputs.
459 const cwd = yield* cloneFixture(fixtureDir, {
460 prefix: "alchemy-staticsite-noop-",
461 entries: ["src", "build.sh", ".gitignore"],
462 });
463 const marker = `staticsite-noop-${Date.now()}`;
464 yield* fs.writeFileString(
465 path.join(cwd, "src", "index.html"),
466 htmlPage(marker),
467 );
468
469 const program = () =>
470 Effect.gen(function* () {
471 return yield* Cloudflare.Website.StaticSite(
472 "NoopSite",
473 staticSiteProps(cwd),
474 );
475 });
476
477 const site = yield* stack.deploy(program());
478 yield* expectWorkerExists(site.workerName, accountId);
479
480 yield* roundTripState;
481
482 // Zero source changes between the two passes, so nothing may plan as
483 // changed. This is the user-visible defect: before the fix the Worker
484 // planned `update` on every single deploy and re-uploaded the whole
485 // asset directory, forever.
486 const settled = yield* stack.plan(program());
487 expect(actionOf(settled, "NoopSite/Build")).toBe("noop");
488 // The Worker is declared at the site's own id (only Build/Dev are
489 // namespaced under it), so its plan row is keyed `NoopSite`.
490 expect(actionOf(settled, "NoopSite")).toBe("noop");
491
492 // ...because the persisted hash is the build's `output` hash — the
493 // content hash of the uploaded bytes — and not the `{ input, output }`
494 // pair, which never compares equal to its own deserialized self.
495 expect(typeof site.hash?.assets).toBe("string");
496
497 yield* stack.destroy();
498 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
499 }).pipe(logLevel),
500 { tags: ["provider:cloudflare:worker"], timeout: 360_000 },
501 );
502
503 test.provider(
504 "StaticSite: a memo:false build has no hash and still uploads the assets",
505 (stack) =>
506 Effect.gen(function* () {
507 const { accountId } = yield* yield* CloudflareEnvironment;
508 const fs = yield* FileSystem.FileSystem;
509 const path = yield* Path.Path;
510
511 yield* stack.destroy();
512
513 const cwd = yield* cloneFixture(fixtureDir, {
514 prefix: "alchemy-staticsite-no-memo-",
515 entries: ["src", "build.sh", ".gitignore"],
516 });
517 const marker = `staticsite-no-memo-${Date.now()}`;
518 yield* fs.writeFileString(
519 path.join(cwd, "src", "index.html"),
520 htmlPage(marker),
521 );
522
523 const site = yield* stack.deploy(
524 Effect.gen(function* () {
525 return yield* Cloudflare.Website.StaticSite("NoMemoSite", {
526 ...staticSiteProps(cwd),
527 memo: false,
528 });
529 }),
530 );
531
532 // `memo: false` computes no hashes, so the site hands the Worker
533 // `hash: undefined` — the hash-*less* shape, which must make the
534 // Worker hash `outdir` itself. Reading an explicit `undefined` as a
535 // supplied hash instead makes a greenfield create compare `undefined
536 // === undefined`, set `skip`, and publish a Worker with no assets.
537 expect(site.hash?.assets).toBeDefined();
538 yield* expectWorkerExists(site.workerName, accountId);
539 yield* expectUrlContains(`${site.url!}/index.html`, marker, {
540 timeout: "120 seconds",
541 label: "memo:false marker",
542 });
543
544 yield* stack.destroy();
545 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
546 }).pipe(logLevel),
547 { tags: ["provider:cloudflare:worker"], timeout: 360_000 },
548 );
549
550 // ─────────────────────────────────────────────────────────────────────
551 // Legacy state migration
552 //
553 // `StaticSite`'s build sub-resource was renamed `Build.Command` →
554 // `Command.Build` and its `attr.hash` shape changed from a bare `string`
555 // to `{ input, output }`. An existing deployment upgrading across the
556 // rename has the old row on disk. Deploying the new `StaticSite` over it
557 // must not error — it re-runs the build (the memoization shape changed)
558 // and republishes the assets, migrating the row to the new type.
559 // ─────────────────────────────────────────────────────────────────────
560 test.provider(
561 "StaticSite: deploys over legacy Build.Command state and republishes assets",
562 (stack) =>
563 Effect.gen(function* () {
564 const { accountId } = yield* yield* CloudflareEnvironment;
565 const fs = yield* FileSystem.FileSystem;
566 const path = yield* Path.Path;
567 const state = yield* yield* State;
568 const stk = yield* Stack;
569
570 yield* stack.destroy();
571
572 const cwd = yield* cloneFixture(fixtureDir, {
573 prefix: "alchemy-staticsite-migrate-",
574 entries: ["src", "build.sh", ".gitignore"],
575 });
576 const marker = `staticsite-migrate-${Date.now()}`;
577 yield* fs.writeFileString(
578 path.join(cwd, "src", "index.html"),
579 htmlPage(marker),
580 );
581
582 // Mirror `examples/.../Website__Build.json`: a pre-rename
583 // `Build.Command` row at the build sub-resource's FQN, carrying the
584 // legacy `attr.hash` string (pre-`{ input, output }`).
585 yield* state.set({
586 stack: stk.name,
587 stage: stk.stage,
588 fqn: "MigSite/Build",
589 value: {
590 status: "created",
591 fqn: "MigSite/Build",
592 logicalId: "Build",
593 instanceId: "606bcaf4c0a931156f5a314ba7b57fc3",
594 resourceType: "Build.Command",
595 props: { command: "bash build.sh", outdir: "dist", env: {} },
596 attr: {
597 outdir: "dist",
598 hash: "a41cbcecfdeb355f6f98130ca4ff8269d0ad5cc3a628f1918d956c71f859648c",
599 },
600 bindings: [],
601 providerVersion: 0,
602 downstream: ["MigSite/Worker"],
603 removalPolicy: "destroy",
604 namespace: { Id: "MigSite" },
605 } satisfies ResourceState,
606 });
607
608 const site = yield* stack.deploy(
609 Effect.gen(function* () {
610 return yield* Cloudflare.Website.StaticSite(
611 "MigSite",
612 staticSiteProps(cwd),
613 );
614 }),
615 );
616
617 expect(site.url).toBeDefined();
618 expect(site.hash?.assets).toBeDefined();
619 yield* expectWorkerExists(site.workerName, accountId);
620 yield* expectUrlContains(`${site.url!}/index.html`, marker, {
621 timeout: "120 seconds",
622 label: "migrated site marker",
623 });
624
625 // The build sub-resource row migrated to the new resource type.
626 const build = (yield* state.get({
627 stack: stk.name,
628 stage: stk.stage,
629 fqn: "MigSite/Build",
630 })) as ResourceState | undefined;
631 expect(build?.resourceType).toBe("Command.Build");
632
633 yield* stack.destroy();
634 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
635 }).pipe(logLevel),
636 { tags: ["provider:cloudflare:worker"], timeout: 360_000 },
637 );
638
639 // ─────────────────────────────────────────────────────────────────────
640 // Orphaned dev-server cleanup across the rename
641 //
642 // A prior `alchemy dev` run writes a `Build.DevServer` row at the
643 // StaticSite's `Dev` sub-resource FQN. A subsequent normal `deploy` never
644 // declares that dev resource, so the row is orphaned and must be torn
645 // down. Before the `Renamed("Build.DevServer" → "Command.Dev")` shim
646 // existed, that teardown failed: the engine couldn't resolve a provider
647 // for the now-nonexistent `Build.DevServer` type and the deploy errored.
648 // This guards that the deploy succeeds and the orphan is reaped.
649 // ─────────────────────────────────────────────────────────────────────
650 test.provider(
651 "StaticSite: deploys cleanly when a legacy Build.DevServer row is orphaned",
652 (stack) =>
653 Effect.gen(function* () {
654 const { accountId } = yield* yield* CloudflareEnvironment;
655 const fs = yield* FileSystem.FileSystem;
656 const path = yield* Path.Path;
657 const state = yield* yield* State;
658 const stk = yield* Stack;
659
660 yield* stack.destroy();
661
662 const cwd = yield* cloneFixture(fixtureDir, {
663 prefix: "alchemy-staticsite-orphan-dev-",
664 entries: ["src", "build.sh", ".gitignore"],
665 });
666 const marker = `staticsite-orphan-dev-${Date.now()}`;
667 yield* fs.writeFileString(
668 path.join(cwd, "src", "index.html"),
669 htmlPage(marker),
670 );
671
672 // Mirror `examples/.../Website__Dev.json`: a pre-rename
673 // `Build.DevServer` row left behind by an earlier `alchemy dev` run,
674 // sitting at the `Dev` sub-resource FQN that a normal deploy never
675 // declares.
676 yield* state.set({
677 stack: stk.name,
678 stage: stk.stage,
679 fqn: "MigSite/Dev",
680 value: {
681 status: "created",
682 fqn: "MigSite/Dev",
683 logicalId: "Dev",
684 instanceId: "b4ca740550ef6e2e409481cedf9314c7",
685 resourceType: "Build.DevServer",
686 props: { command: "zola serve", env: {} },
687 attr: { url: "http://127.0.0.1:1024" },
688 bindings: [],
689 providerVersion: 0,
690 downstream: ["MigSite/Worker"],
691 removalPolicy: "destroy",
692 namespace: { Id: "MigSite" },
693 } satisfies ResourceState,
694 });
695
696 const site = yield* stack.deploy(
697 Effect.gen(function* () {
698 return yield* Cloudflare.Website.StaticSite(
699 "MigSite",
700 staticSiteProps(cwd),
701 );
702 }),
703 );
704
705 expect(site.url).toBeDefined();
706 yield* expectWorkerExists(site.workerName, accountId);
707 yield* expectUrlContains(`${site.url!}/index.html`, marker, {
708 timeout: "120 seconds",
709 label: "orphan-dev site marker",
710 });
711
712 // The orphaned dev-server row was reaped via the renamed provider.
713 const orphan = yield* state.get({
714 stack: stk.name,
715 stage: stk.stage,
716 fqn: "MigSite/Dev",
717 });
718 expect(orphan).toBeUndefined();
719
720 yield* stack.destroy();
721 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
722 }).pipe(logLevel),
723 { tags: ["provider:cloudflare:worker"], timeout: 360_000 },
724 );
725
726 test.provider(
727 "StaticSite: build env resolves Config, Output, and JSON values (#796)",
728 (stack) =>
729 Effect.gen(function* () {
730 const fs = yield* FileSystem.FileSystem;
731 const path = yield* Path.Path;
732
733 yield* stack.destroy();
734
735 // A build that snapshots the env vars it receives into its output.
736 const cwd = yield* fs.makeTempDirectory({
737 prefix: "alchemy-staticsite-env-",
738 });
739 yield* fs.writeFileString(
740 path.join(cwd, "build.sh"),
741 [
742 "#!/bin/bash",
743 "set -euo pipefail",
744 "mkdir -p dist",
745 "{",
746 ' echo "FROM_STRING=${FROM_STRING:-missing}"',
747 ' echo "FROM_CONFIG=${FROM_CONFIG:-missing}"',
748 ' echo "FROM_OUTPUT=${FROM_OUTPUT:-missing}"',
749 ' echo "FROM_OUTPUT_OBJECT=${FROM_OUTPUT_OBJECT:-missing}"',
750 ' echo "FROM_NULL=${FROM_NULL:-missing}"',
751 "} > dist/env.txt",
752 ].join("\n"),
753 );
754
755 // A sibling resource whose attributes provide real `Output` values.
756 const depCwd = yield* fs.makeTempDirectory({
757 prefix: "alchemy-staticsite-env-dep-",
758 });
759 yield* fs.writeFileString(
760 path.join(depCwd, "build.sh"),
761 "#!/bin/bash\nmkdir -p dist\necho dep > dist/dep.txt\n",
762 );
763
764 // `STATICSITE_ENV_TEST` is set at module load (see top of file) —
765 // the harness's ConfigProvider snapshots process.env before the
766 // test body runs.
767 yield* stack.deploy(
768 Effect.gen(function* () {
769 const dep = yield* Command.Build("EnvDep", {
770 command: "bash build.sh",
771 shell: true,
772 cwd: depCwd,
773 outdir: "dist",
774 });
775 return yield* Cloudflare.Website.StaticSite("EnvSite", {
776 command: "bash build.sh",
777 shell: true,
778 cwd,
779 outdir: "dist",
780 workersDev: false,
781 env: {
782 FROM_STRING: "plain",
783 FROM_CONFIG: Config.String("STATICSITE_ENV_TEST"),
784 FROM_OUTPUT: dep.outdir,
785 FROM_OUTPUT_OBJECT: Output.map(dep.outdir, (dir) => ({
786 dir,
787 })),
788 // JS callers can pass `null`; it must serialize, not throw.
789 FROM_NULL: null as unknown as string,
790 },
791 });
792 }),
793 );
794
795 const envFile = yield* fs.readFileString(
796 path.join(cwd, "dist", "env.txt"),
797 );
798 expect(envFile).toContain("FROM_STRING=plain");
799 expect(envFile).toContain("FROM_CONFIG=from-config");
800 // Resolved Output<string> — the dep's outdir path, not garbage.
801 const fromOutput = envFile.match(/FROM_OUTPUT=(.*)/)?.[1];
802 expect(fromOutput).toBeDefined();
803 expect(fromOutput).not.toBe("missing");
804 expect(fromOutput).toContain("dist");
805 expect(fromOutput).not.toContain("[object");
806 // Resolved Output<object> — serialized as JSON.
807 const fromOutputObject = envFile.match(
808 /FROM_OUTPUT_OBJECT=(.*)/,
809 )?.[1];
810 expect(fromOutputObject).toContain('{"dir":');
811 expect(envFile).toContain("FROM_NULL=null");
812
813 yield* stack.destroy();
814 }).pipe(logLevel),
815 { timeout: 360_000 },
816 );
817
818 // ─────────────────────────────────────────────────────────────────────
819 // SPA fallback: `assets.notFoundHandling: "single-page-application"`
820 //
821 // An assets-only deploy (no `main`) where Cloudflare's asset layer
822 // answers every request itself: an unmatched deep link must fall back
823 // to `index.html` with a 200 so a client router can boot, while real
824 // assets keep serving their own bytes.
825 // ─────────────────────────────────────────────────────────────────────
826
827 test.provider(
828 "StaticSite: SPA not-found handling serves index.html for deep links",
829 (stack) =>
830 Effect.gen(function* () {
831 const { accountId } = yield* yield* CloudflareEnvironment;
832 const fs = yield* FileSystem.FileSystem;
833 const path = yield* Path.Path;
834
835 yield* stack.destroy();
836
837 const cwd = yield* cloneFixture(fixtureDir, {
838 prefix: "alchemy-staticsite-spa-",
839 entries: ["src", "build.sh", ".gitignore"],
840 });
841 // Deterministic marker: the shell content is the assertion target
842 // for both the direct fetch and the deep-link fallback.
843 const marker = "staticsite-spa-shell";
844 yield* fs.writeFileString(
845 path.join(cwd, "src", "index.html"),
846 htmlPage(marker),
847 );
848 // A sibling asset that must keep serving its own bytes, not the
849 // shell, under SPA handling.
850 yield* fs.writeFileString(
851 path.join(cwd, "src", "data.txt"),
852 "staticsite-spa-plain-asset",
853 );
854 // Binary assets whose uploads must carry real content types
855 // (avif/jpeg/webp/woff2 previously fell back to
856 // application/octet-stream).
857 for (const name of [
858 "sample.avif",
859 "sample.jpg",
860 "sample.webp",
861 "sample.woff2",
862 ]) {
863 yield* fs.writeFile(
864 path.join(cwd, "src", name),
865 new Uint8Array([0x00, 0x01, 0x02, 0x03]),
866 );
867 }
868
869 const site = yield* stack.deploy(
870 Effect.gen(function* () {
871 return yield* Cloudflare.Website.StaticSite("SpaSite", {
872 command: "bash build.sh",
873 shell: true,
874 cwd,
875 outdir: "dist",
876 workersDev: true,
877 compatibility: { date: "2024-01-01" },
878 // Assets-only (no `main`): with a Worker script in front,
879 // unmatched requests would invoke the Worker instead of the
880 // asset layer's not-found handling.
881 assets: { notFoundHandling: "single-page-application" },
882 });
883 }),
884 );
885
886 expect(site.url).toBeDefined();
887 expect(site.hash?.assets).toBeDefined();
888 yield* expectWorkerExists(site.workerName, accountId);
889
890 // The shell serves at its real path.
891 yield* expectUrlContains(`${site.url!}/index.html`, marker, {
892 timeout: "120 seconds",
893 label: "SPA shell at /index.html",
894 });
895
896 // A hard GET to a route that matches no asset falls back to the
897 // shell with a 200 — `expectUrlContains` requires `res.ok`, so a
898 // plain 404 can't satisfy this.
899 yield* expectUrlContains(`${site.url!}/app/deep/route`, marker, {
900 timeout: "60 seconds",
901 label: "deep link falls back to index.html",
902 });
903
904 // A real asset still serves its own bytes, not the shell.
905 const assetBody = yield* expectUrlContains(
906 `${site.url!}/data.txt`,
907 "staticsite-spa-plain-asset",
908 {
909 timeout: "60 seconds",
910 label: "plain asset with SPA handling",
911 },
912 );
913 expect(assetBody).not.toContain(marker);
914
915 // Content types for common binary asset formats survive upload.
916 for (const [name, contentType] of [
917 ["sample.avif", "image/avif"],
918 ["sample.jpg", "image/jpeg"],
919 ["sample.webp", "image/webp"],
920 ["sample.woff2", "font/woff2"],
921 ] as const) {
922 yield* expectUrlHeader(
923 `${site.url!}/${name}`,
924 "content-type",
925 contentType,
926 );
927 }
928
929 yield* stack.destroy();
930 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
931 }).pipe(logLevel),
932 { tags: ["provider:cloudflare:worker"], timeout: 360_000 },
933 );
934 },
935);
936
937const staticSiteProps = (cwd: string): Cloudflare.Website.StaticSiteProps => ({
938 command: "bash build.sh",
939 shell: true,
940 cwd,
941 outdir: "dist",
942 main: workerEntry,
943 workersDev: true,
944 compatibility: { date: "2024-01-01" },
945});
946
947const htmlPage = (marker: string) => `<!doctype html>
948<html>
949 <head><title>${marker}</title></head>
950 <body><h1>${marker}</h1></body>
951</html>
952`;
953
954/**
955 * Inverse of `expectUrlContains`: succeeds if the marker is *absent*
956 * from the response within the timeout. We drive this off the same
957 * primitive by inverting the check at the call site.
958 */
959const expectUrlAbsent = (
960 url: string,
961 marker: string,
962 options: { timeout?: Duration.Input },
963) =>
964 Effect.gen(function* () {
965 yield* expectUrlContains(url, "<", { ...options, label: "page exists" });
966 const u = new URL(url);
967 u.searchParams.set("__alchemy_cb", String(Date.now()));
968 const body = yield* Effect.promise(() =>
969 fetch(u, {
970 cache: "no-store",
971 headers: { "cache-control": "no-cache" },
972 }).then((r) => r.text()),
973 );
974 expect(
975 body.includes(marker),
976 `expected URL ${url} to NOT contain "${marker}", but it did`,
977 ).toBe(false);
978 });