1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import { WorkerDomainConfigError } from "@/Cloudflare/Workers/WorkerProvider.ts";
4import { findZoneByName } from "@/Cloudflare/Zone/lookup";
5import * as Test from "@/Test/Alchemy";
6import * as rulesets from "@distilled.cloud/cloudflare/rulesets";
7import { describe, expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12import { expectUrlContains } from "../Utils/Http.ts";
13import { waitForWorkerToBeDeleted } from "../Utils/Worker.ts";
14
15const { test } = Test.make({ providers: Cloudflare.providers() });
16
17const logLevel = Effect.provideService(
18 MinimumLogLevel,
19 process.env.DEBUG ? "Debug" : "Info",
20);
21
22const script = (marker: string) =>
23 `export default { fetch() { return new Response("${marker}"); } };`;
24
25class RedirectNotReady extends Data.TaggedError("RedirectNotReady")<{
26 url: string;
27 detail: string;
28}> {}
29
30class DnsNotReady extends Data.TaggedError("DnsNotReady")<{
31 hostname: string;
32}> {}
33
34/**
35 * Wait until `hostname` resolves, querying 1.1.1.1 over DNS-over-HTTPS.
36 * The system resolver must not be asked before the record exists: a Worker
37 * custom domain's DNS record appears a few seconds after attach, and an
38 * early lookup negative-caches NXDOMAIN for the zone's SOA minimum TTL
39 * (30 minutes on Cloudflare) — poisoning every later fetch in the test.
40 * DoH bypasses that cache entirely, so the first system-resolver query
41 * only happens once the record is live.
42 */
43const waitForDns = Effect.fn(function* (hostname: string) {
44 yield* Effect.tryPromise({
45 try: async (signal) => {
46 const res = await fetch(
47 `https://1.1.1.1/dns-query?name=${hostname}&type=AAAA`,
48 { headers: { accept: "application/dns-json" }, signal },
49 );
50 const body = (await res.json()) as { Answer?: unknown[] };
51 if (!body.Answer?.length) throw new Error("no answer");
52 },
53 catch: () => new DnsNotReady({ hostname }),
54 }).pipe(
55 Effect.retry({
56 while: (e) => e._tag === "DnsNotReady",
57 schedule: Schedule.spaced("5 seconds"),
58 times: 36,
59 }),
60 );
61});
62
63/**
64 * Fetch `from` without following redirects and assert a 301 whose
65 * `location` matches `expectedLocation` exactly (path + query preserved).
66 * Retried through DNS/certificate propagation on a freshly attached
67 * custom domain.
68 */
69const expectRedirect = Effect.fn(function* (
70 from: string,
71 expectedLocation: string,
72) {
73 const location = yield* Effect.tryPromise({
74 try: async (signal) => {
75 const res = await fetch(from, { redirect: "manual", signal });
76 if (res.status !== 301) {
77 throw new Error(`expected 301, got ${res.status}`);
78 }
79 return res.headers.get("location") ?? "";
80 },
81 catch: (cause) =>
82 new RedirectNotReady({ url: from, detail: String(cause) }),
83 }).pipe(
84 Effect.flatMap((location) =>
85 location === expectedLocation
86 ? Effect.succeed(location)
87 : Effect.fail(
88 new RedirectNotReady({
89 url: from,
90 detail: `location was '${location}', expected '${expectedLocation}'`,
91 }),
92 ),
93 ),
94 Effect.retry({
95 while: (e) => e._tag === "RedirectNotReady",
96 schedule: Schedule.spaced("5 seconds"),
97 times: 48,
98 }),
99 );
100 expect(location).toEqual(expectedLocation);
101});
102
103/** Our redirect rules in the zone's dynamic-redirect phase entrypoint. */
104const listWorkerRedirectRules = Effect.fn(function* (
105 zoneId: string,
106 scriptName: string,
107) {
108 const entrypoint = yield* rulesets
109 .getPhasForZone({ zoneId, rulesetPhase: "http_request_dynamic_redirect" })
110 .pipe(Effect.catch(() => Effect.succeed(undefined)));
111 return (entrypoint?.rules ?? []).flatMap((rule) =>
112 (rule.description ?? "").startsWith(
113 `alchemy:worker:${scriptName}:redirect:`,
114 )
115 ? [rule.description as string]
116 : [],
117 );
118});
119
120describe.concurrent(
121 "Cloudflare.Worker urls & domain",
122 { tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"] },
123 () => {
124 test.provider(
125 "rejects a hostname playing more than one role",
126 (stack) =>
127 Effect.gen(function* () {
128 yield* stack.destroy();
129
130 const error = yield* stack
131 .deploy(
132 Effect.gen(function* () {
133 return yield* Cloudflare.Worker("OverlapWorker", {
134 script: script("overlap"),
135 domain: {
136 name: "app.example.com",
137 aliases: ["app.example.com"],
138 },
139 });
140 }),
141 )
142 .pipe(Effect.flip);
143
144 expect(error).toBeInstanceOf(WorkerDomainConfigError);
145 expect(String(error)).toContain("one role");
146
147 yield* stack.destroy();
148 }).pipe(logLevel),
149 { timeout: 120_000 },
150 );
151
152 const customDomainZone =
153 process.env.CLOUDFLARE_TEST_WORKER_DOMAIN_ZONE_NAME;
154 test.provider.skipIf(!customDomainZone)(
155 "redirect hostnames 301 to the canonical name and stay out of urls",
156 (stack) =>
157 Effect.gen(function* () {
158 const { accountId } = yield* yield* CloudflareEnvironment;
159 const suffix =
160 process.env.PULL_REQUEST ?? process.env.USER ?? "local";
161 const mainHost = `alchemy-rdr-a-${suffix}.${customDomainZone}`;
162 const oldHost = `alchemy-rdr-b-${suffix}.${customDomainZone}`;
163 const aliasHost = `alchemy-rdr-c-${suffix}.${customDomainZone}`;
164
165 yield* stack.destroy();
166
167 const deploy = (domain: {
168 name: string;
169 aliases?: string[];
170 redirects?: string[];
171 }) =>
172 stack.deploy(
173 Effect.gen(function* () {
174 return yield* Cloudflare.Worker("RedirectWorker", {
175 script: script("redirect-target"),
176 domain,
177 });
178 }),
179 );
180
181 const worker = yield* deploy({
182 name: mainHost,
183 aliases: [aliasHost],
184 redirects: [oldHost],
185 });
186
187 // All three roles resolve into the domain output; the redirect
188 // hostname serves nothing, so it must not appear in `urls`.
189 expect(worker.domain).toEqual({
190 name: mainHost,
191 aliases: [aliasHost],
192 redirects: [oldHost],
193 });
194 expect(worker.url).toEqual(`https://${mainHost}`);
195 expect(worker.urls.slice(0, 2)).toEqual([
196 `https://${mainHost}`,
197 `https://${aliasHost}`,
198 ]);
199 expect(worker.urls.some((u) => u.includes(oldHost))).toBe(false);
200 // workers.dev stays on by default and ranks after the domain.
201 expect(worker.urls[worker.urls.length - 1]).toMatch(
202 /\.workers\.dev$/,
203 );
204
205 // Our tagged rule exists in the zone's shared entrypoint — one for
206 // the redirect host, none for the alias (aliases serve, not 301).
207 const zone = yield* findZoneByName({
208 accountId,
209 name: customDomainZone!,
210 });
211 expect(zone).toBeDefined();
212 expect(
213 yield* listWorkerRedirectRules(zone!.id, worker.workerName),
214 ).toEqual([
215 `alchemy:worker:${worker.workerName}:redirect:${oldHost}`,
216 ]);
217
218 // The canonical domain AND the alias serve the Worker; the
219 // redirect host 301s with path and query preserved — and never
220 // invokes the Worker. Confirm DNS over DoH before the first fetch
221 // (see waitForDns).
222 yield* waitForDns(mainHost);
223 yield* waitForDns(aliasHost);
224 yield* waitForDns(oldHost);
225 yield* expectUrlContains(worker.url!, "redirect-target", {
226 label: "canonical domain serves the worker",
227 timeout: "120 seconds",
228 });
229 yield* expectUrlContains(`https://${aliasHost}`, "redirect-target", {
230 label: "alias serves the worker",
231 timeout: "120 seconds",
232 });
233 yield* expectRedirect(
234 `https://${oldHost}/hello?x=1`,
235 `https://${mainHost}/hello?x=1`,
236 );
237
238 // Removing the redirect cleans our rule and detaches nothing else.
239 const updated = yield* deploy({ name: mainHost });
240 expect(updated.domain).toEqual({
241 name: mainHost,
242 aliases: [],
243 redirects: [],
244 });
245 expect(
246 yield* listWorkerRedirectRules(zone!.id, updated.workerName),
247 ).toEqual([]);
248
249 // Re-add, then destroy — teardown must remove the rule too.
250 const readded = yield* deploy({
251 name: mainHost,
252 redirects: [oldHost],
253 });
254 expect(
255 yield* listWorkerRedirectRules(zone!.id, readded.workerName),
256 ).toHaveLength(1);
257 yield* stack.destroy();
258 expect(
259 yield* listWorkerRedirectRules(zone!.id, readded.workerName),
260 ).toEqual([]);
261 yield* waitForWorkerToBeDeleted(readded.workerName, accountId);
262 }).pipe(logLevel),
263 {
264 tags: ["provider:cloudflare:ruleset", "provider:cloudflare:zone"],
265 timeout: 540_000,
266 },
267 );
268
269 test.provider.skipIf(!customDomainZone)(
270 "domain string shorthand resolves to { name }",
271 (stack) =>
272 Effect.gen(function* () {
273 const suffix =
274 process.env.PULL_REQUEST ?? process.env.USER ?? "local";
275 const host = `alchemy-shorthand-${suffix}.${customDomainZone}`;
276
277 yield* stack.destroy();
278
279 const worker = yield* stack.deploy(
280 Effect.gen(function* () {
281 return yield* Cloudflare.Worker("ShorthandWorker", {
282 script: script("shorthand"),
283 domain: host,
284 workersDev: false,
285 });
286 }),
287 );
288
289 expect(worker.domain).toEqual({
290 name: host,
291 aliases: [],
292 redirects: [],
293 });
294 expect(worker.url).toEqual(`https://${host}`);
295 expect(worker.urls).toEqual([`https://${host}`]);
296
297 yield* stack.destroy();
298 }).pipe(logLevel),
299 { timeout: 240_000 },
300 );
301 },
302);