1import * as Cloudflare from "@/Cloudflare/index.ts";
2import * as Test from "@/Test/Alchemy";
3import { describe } from "alchemy-test";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import * as Schedule from "effect/Schedule";
9import * as pathe from "pathe";
10import { cloneFixture } from "../Utils/Fixture.ts";
11import { expectUrlContains } from "../Utils/Http.ts";
12
13const { test } = Test.make({ providers: Cloudflare.providers() });
14
15const fixtureDir = pathe.resolve(import.meta.dirname, "fixtures/assets-only");
16
17class NotFoundAssertionFailed extends Data.TaggedError(
18 "NotFoundAssertionFailed",
19)<{
20 url: string;
21 status: number;
22 bodyExcerpt: string;
23}> {}
24
25/**
26 * Assert `url` serves the custom 404 page: status 404 with the fixture's
27 * `404.html` marker in the body. `expectUrlContains` requires `res.ok`,
28 * so this needs its own retry loop.
29 */
30const expectCustom404 = (url: string) =>
31 Effect.tryPromise({
32 try: async (signal) => {
33 const res = await fetch(url, { signal, cache: "no-store" });
34 const body = await res.text();
35 if (res.status !== 404 || !body.includes("alchemy-assets-only-404")) {
36 throw new NotFoundAssertionFailed({
37 url,
38 status: res.status,
39 bodyExcerpt: body.slice(0, 240),
40 });
41 }
42 },
43 catch: (e) =>
44 e instanceof NotFoundAssertionFailed
45 ? e
46 : new NotFoundAssertionFailed({
47 url,
48 status: 0,
49 bodyExcerpt: e instanceof Error ? e.message : String(e),
50 }),
51 }).pipe(
52 Effect.retry({
53 schedule: Schedule.exponential("750 millis", 1.5),
54 times: 10,
55 }),
56 );
57
58describe.concurrent(
59 "Cloudflare.Worker assets-only",
60 { tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"] },
61 () => {
62 test.provider(
63 "deploys, updates, and converts an assets-only Worker",
64 (stack) =>
65 Effect.gen(function* () {
66 const fs = yield* FileSystem.FileSystem;
67 const path = yield* Path.Path;
68
69 yield* stack.destroy();
70
71 const deploy = (props: {
72 assets:
73 | string
74 | { directory: string; notFoundHandling?: "404-page" };
75 script?: string;
76 }) =>
77 stack.deploy(
78 Effect.gen(function* () {
79 return yield* Cloudflare.Worker("AssetsOnly", {
80 ...props,
81 workersDev: true,
82 compatibility: { date: "2024-01-01" },
83 });
84 }),
85 );
86
87 // 1. Fresh assets-only deploy: no main, no script — Cloudflare's
88 // asset layer serves everything, including the custom 404 page.
89 const worker = yield* deploy({
90 assets: { directory: fixtureDir, notFoundHandling: "404-page" },
91 });
92 const url = worker.url!;
93 yield* expectUrlContains(`${url}/`, "alchemy-assets-only-index");
94 yield* expectCustom404(`${url}/does-not-exist`);
95
96 // 2. Update: editing an asset must redeploy the new content.
97 const dir = yield* cloneFixture(fixtureDir, {
98 prefix: "alchemy-assets-only-",
99 });
100 yield* fs.writeFileString(
101 path.join(dir, "index.html"),
102 "<html><body>alchemy-assets-only-index-v2</body></html>",
103 );
104 yield* deploy({
105 assets: { directory: dir, notFoundHandling: "404-page" },
106 });
107 yield* expectUrlContains(`${url}/`, "alchemy-assets-only-index-v2", {
108 label: "updated asset",
109 });
110
111 // 3. Convert to a script Worker: unmatched routes now invoke the
112 // script instead of the asset layer's 404 handling.
113 yield* deploy({
114 assets: dir,
115 script: `export default { fetch: () => new Response("alchemy-assets-only-script") };`,
116 });
117 yield* expectUrlContains(
118 `${url}/does-not-exist`,
119 "alchemy-assets-only-script",
120 { label: "script fallback after conversion" },
121 );
122
123 // 4. Convert back to assets-only: the stored bundle hash must not
124 // mask the change, and the asset layer owns 404s again.
125 yield* deploy({
126 assets: { directory: dir, notFoundHandling: "404-page" },
127 });
128 yield* expectCustom404(`${url}/does-not-exist`);
129 yield* expectUrlContains(`${url}/`, "alchemy-assets-only-index-v2", {
130 label: "assets serve after conversion back",
131 });
132
133 yield* stack.destroy();
134 }),
135 { timeout: 360_000 },
136 );
137
138 test.provider(
139 "class form deploys an assets-only Worker",
140 (stack) =>
141 Effect.gen(function* () {
142 yield* stack.destroy();
143
144 class Site extends Cloudflare.Worker<Site>()("AssetsOnlyClass", {
145 assets: {
146 directory: fixtureDir,
147 notFoundHandling: "404-page",
148 },
149 workersDev: true,
150 compatibility: { date: "2024-01-01" },
151 }) {}
152
153 const worker = yield* stack.deploy(
154 Effect.gen(function* () {
155 return yield* Site;
156 }),
157 );
158 const url = worker.url!;
159 yield* expectUrlContains(`${url}/`, "alchemy-assets-only-index");
160 yield* expectCustom404(`${url}/does-not-exist`);
161
162 yield* stack.destroy();
163 }),
164 { timeout: 240_000 },
165 );
166 },
167);