1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import { findZoneByName } from "@/Cloudflare/Zone/lookup";
4import * as Test from "@/Test/Alchemy";
5import * as workers from "@distilled.cloud/cloudflare/workers";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import * as pathe from "pathe";
11import { waitForWorkerToBeDeleted } from "../Utils/Worker.ts";
12
13const { test } = Test.make({ providers: Cloudflare.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20const main = pathe.resolve(import.meta.dirname, "fixtures/worker.ts");
21
22const zoneName =
23 process.env.CLOUDFLARE_TEST_WORKER_DOMAIN_ZONE_NAME ??
24 process.env.CLOUDFLARE_TEST_R2_DOMAIN_ZONE_NAME ??
25 "alchemy-test-2.us";
26
27// Deterministic per-run hostnames on the standing test zone. Each rerun
28// reuses the same hostnames (never derive names from Date.now()).
29const domainSuffix = `alchemy-worker-domain-${process.env.PULL_REQUEST ?? process.env.USER}`;
30
31const DECLARED_HOSTNAME = `wd1-${domainSuffix}.${zoneName}`;
32const OUT_OF_BAND_HOSTNAME = `wd2-${domainSuffix}.${zoneName}`;
33
34const resolveZoneId = Effect.gen(function* () {
35 const { accountId } = yield* yield* CloudflareEnvironment;
36 const zone = yield* findZoneByName({ accountId, name: zoneName });
37 if (!zone) {
38 return yield* Effect.die(
39 new Error(`zone "${zoneName}" not found in account`),
40 );
41 }
42 return zone.id;
43});
44
45// Retry rate-limit blips on the tests' own out-of-band verification calls.
46const transientRetrySchedule = Schedule.exponential("500 millis");
47
48const listAttachments = (hostname: string) =>
49 Effect.gen(function* () {
50 const { accountId } = yield* yield* CloudflareEnvironment;
51 return yield* workers.listDomains({ accountId, hostname }).pipe(
52 Effect.map((r) =>
53 (r.result ?? []).filter((d) => d.hostname === hostname),
54 ),
55 Effect.retry({
56 while: (e) => e._tag === "TooManyRequests",
57 schedule: transientRetrySchedule,
58 times: 8,
59 }),
60 );
61 });
62
63const findAttachment = (hostname: string) =>
64 listAttachments(hostname).pipe(Effect.map((ds) => ds[0]));
65
66// Detach every worker-domain attachment for the given hostnames — purge
67// leftovers from interrupted runs so tests start from a clean slate.
68const purgeDomains = (...hostnames: string[]) =>
69 Effect.gen(function* () {
70 const { accountId } = yield* yield* CloudflareEnvironment;
71 yield* Effect.forEach(hostnames, (hostname) =>
72 listAttachments(hostname).pipe(
73 Effect.flatMap(
74 Effect.forEach((d) =>
75 d.id
76 ? workers
77 .deleteDomain({ accountId, domainId: d.id })
78 .pipe(Effect.catch(() => Effect.void))
79 : Effect.void,
80 ),
81 ),
82 ),
83 );
84 });
85
86// #942: an omitted `domain` prop must leave live attachments alone —
87// including hostnames attached outside Alchemy — while `domain: null` is the
88// explicit detach-all. The gate keys on declared intent (props), never on
89// domains persisted in state, so state written by older providers that
90// observed listDomains unconditionally cannot re-arm the destructive path.
91test.provider(
92 "omitted domain preserves live attachments; null detaches all",
93 (stack) =>
94 Effect.gen(function* () {
95 const { accountId } = yield* yield* CloudflareEnvironment;
96 const zoneId = yield* resolveZoneId;
97
98 yield* stack.destroy();
99 yield* purgeDomains(DECLARED_HOSTNAME, OUT_OF_BAND_HOSTNAME);
100
101 let workerName: string | undefined;
102
103 yield* Effect.gen(function* () {
104 // Create — one declared custom domain.
105 const worker = yield* stack.deploy(
106 Effect.gen(function* () {
107 return yield* Cloudflare.Worker("DomainWorker", {
108 main,
109 workersDev: false,
110 compatibility: { date: "2024-01-01" },
111 domain: DECLARED_HOSTNAME,
112 });
113 }),
114 );
115 workerName = worker.workerName;
116
117 expect(worker.urls).toEqual([`https://${DECLARED_HOSTNAME}`]);
118 expect(worker.domain).toEqual({
119 name: DECLARED_HOSTNAME,
120 aliases: [],
121 redirects: [],
122 });
123 const declared = yield* findAttachment(DECLARED_HOSTNAME);
124 expect(declared?.service).toEqual(worker.workerName);
125
126 // Attach a second hostname to the same script out-of-band — the
127 // reconciler never recorded it, so it is unmanaged drift.
128 yield* workers
129 .putDomain({
130 accountId,
131 hostname: OUT_OF_BAND_HOSTNAME,
132 service: worker.workerName,
133 zoneId,
134 })
135 .pipe(
136 // Same eventual-consistency window as the provider's own
137 // attachDomain: PUT domains right after putScript can race the
138 // script registry and return WorkerNotFound.
139 Effect.retry({
140 while: (e): boolean =>
141 e._tag === "WorkerNotFound" || e._tag === "TooManyRequests",
142 schedule: transientRetrySchedule,
143 times: 8,
144 }),
145 );
146
147 // Update with `domain` omitted (compat date bump forces the
148 // update): both attachments must survive — omission unmanages the
149 // surface, it does not mean "detach everything".
150 const unmanaged = yield* stack.deploy(
151 Effect.gen(function* () {
152 return yield* Cloudflare.Worker("DomainWorker", {
153 main,
154 workersDev: false,
155 compatibility: { date: "2024-01-02" },
156 });
157 }),
158 );
159
160 expect((yield* findAttachment(DECLARED_HOSTNAME))?.service).toEqual(
161 worker.workerName,
162 );
163 expect((yield* findAttachment(OUT_OF_BAND_HOSTNAME))?.service).toEqual(
164 worker.workerName,
165 );
166 // Previously-observed custom domains carry forward in state so
167 // subsequent reads keep observing them.
168 expect(unmanaged.domain?.name).toEqual(DECLARED_HOSTNAME);
169 expect(unmanaged.urls).toContain(`https://${DECLARED_HOSTNAME}`);
170
171 // The unmanaged surface must also converge: the custom domains
172 // carried forward in state must not diff dirty against the omitted
173 // `domain` prop — an identical program plans as a noop.
174 const settled = yield* stack.plan(
175 Effect.gen(function* () {
176 return yield* Cloudflare.Worker("DomainWorker", {
177 main,
178 workersDev: false,
179 compatibility: { date: "2024-01-02" },
180 });
181 }),
182 );
183 const settledAction = (Object.values(settled.resources) as any[]).find(
184 (node: any) => node.resource.LogicalId === "DomainWorker",
185 )?.action;
186 expect(settledAction).toBe("noop");
187
188 // `domain: null` is the explicit detach-all.
189 const detached = yield* stack.deploy(
190 Effect.gen(function* () {
191 return yield* Cloudflare.Worker("DomainWorker", {
192 main,
193 workersDev: false,
194 compatibility: { date: "2024-01-02" },
195 domain: null,
196 });
197 }),
198 );
199
200 expect(detached.domain).toBeUndefined();
201 expect(detached.urls).toHaveLength(0);
202 expect(yield* findAttachment(DECLARED_HOSTNAME)).toBeUndefined();
203 expect(yield* findAttachment(OUT_OF_BAND_HOSTNAME)).toBeUndefined();
204 }).pipe(
205 Effect.ensuring(
206 Effect.gen(function* () {
207 yield* stack.destroy().pipe(Effect.ignore);
208 yield* purgeDomains(DECLARED_HOSTNAME, OUT_OF_BAND_HOSTNAME).pipe(
209 Effect.ignore,
210 );
211 if (workerName) {
212 yield* waitForWorkerToBeDeleted(workerName, accountId).pipe(
213 Effect.ignore,
214 );
215 }
216 }),
217 ),
218 );
219 }).pipe(logLevel),
220 {
221 tags: [
222 "provider:cloudflare",
223 "provider:cloudflare:worker",
224 "provider:cloudflare:zone",
225 "live",
226 ],
227 timeout: 300_000,
228 },
229);