1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import { WorkerVersionConfigError } 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 * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
8import { describe, expect } from "alchemy-test";
9import * as Data from "effect/Data";
10import type * as Duration from "effect/Duration";
11import * as Effect from "effect/Effect";
12import { MinimumLogLevel } from "effect/References";
13import * as Schedule from "effect/Schedule";
14import * as Schema from "effect/Schema";
15import * as HttpClient from "effect/http/HttpClient";
16import * as HttpClientResponse from "effect/http/HttpClientResponse";
17
18const { test } = Test.make({ providers: Cloudflare.providers() });
19
20const logLevel = Effect.provideService(
21 MinimumLogLevel,
22 process.env.DEBUG ? "Debug" : "Info",
23);
24
25// The worker echoes the version-key header the transform rule sets, so a
26// plain fetch proves the rule rewrote the request end-to-end.
27const script = `export default { fetch(request) { return new Response(request.headers.get("Cloudflare-Workers-Version-Key") ?? "no-key"); } };`;
28
29const zoneName =
30 process.env.CLOUDFLARE_TEST_WORKER_DOMAIN_ZONE_NAME ??
31 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ??
32 "alchemy-test-2.us";
33
34// Deterministic per-run hostnames on the standing test zone (never derive
35// names from Date.now()).
36const suffix = process.env.PULL_REQUEST ?? process.env.USER ?? "local";
37
38const resolveZone = Effect.gen(function* () {
39 const { accountId } = yield* yield* CloudflareEnvironment;
40 const zone = yield* findZoneByName({ accountId, name: zoneName });
41 if (!zone) {
42 return yield* Effect.die(
43 new Error(`zone "${zoneName}" not found in account`),
44 );
45 }
46 return zone;
47});
48
49/**
50 * Our affinity rules in the zone's late-transform phase entrypoint, as
51 * `{ description, expression, value }` (value = the header-value
52 * expression), sorted by description.
53 */
54const listAffinityRules = Effect.fn(function* (
55 zoneId: string,
56 scriptName: string,
57) {
58 const entrypoint = yield* rulesets
59 .getPhasForZone({ zoneId, rulesetPhase: "http_request_late_transform" })
60 .pipe(Effect.catch(() => Effect.succeed(undefined)));
61 return (entrypoint?.rules ?? [])
62 .flatMap((rule) => {
63 if (
64 !(rule.description ?? "").startsWith(
65 `alchemy:worker:${scriptName}:affinity`,
66 )
67 ) {
68 return [];
69 }
70 const headers =
71 "actionParameters" in rule
72 ? (
73 rule.actionParameters as
74 | { headers?: Record<string, unknown> | null }
75 | null
76 | undefined
77 )?.headers
78 : undefined;
79 const header = headers?.["Cloudflare-Workers-Version-Key"] as
80 | { expression?: unknown }
81 | undefined;
82 return [
83 {
84 description: rule.description as string,
85 expression: rule.expression ?? "",
86 value:
87 typeof header?.expression === "string"
88 ? header.expression
89 : undefined,
90 },
91 ];
92 })
93 .sort((a, b) => a.description.localeCompare(b.description));
94});
95
96class DnsNotReady extends Data.TaggedError("DnsNotReady")<{
97 hostname: string;
98}> {}
99
100const DnsResponse = Schema.Struct({
101 Status: Schema.Number,
102 Answer: Schema.optional(
103 Schema.Array(Schema.Struct({ type: Schema.Number, data: Schema.String })),
104 ),
105});
106
107// Use public DNS answers for the socket lookup too: the OS can retain a
108// negative answer from an earlier delete even after public DNS has converged.
109const domainClient = Effect.fn(function* (hostname: string) {
110 const resolvers = ["https://1.1.1.1/dns-query", "https://dns.google/resolve"];
111 let attempt = 0;
112 const addresses = yield* Effect.suspend(() =>
113 HttpClient.get(resolvers[attempt++ % resolvers.length], {
114 headers: { accept: "application/dns-json" },
115 urlParams: { name: hostname, type: "A" },
116 }),
117 ).pipe(
118 Effect.flatMap(HttpClientResponse.schemaBodyJson(DnsResponse)),
119 Effect.flatMap((response) => {
120 const addresses = (response.Answer ?? [])
121 .filter((answer) => answer.type === 1)
122 .map((answer) => ({ address: answer.data, family: 4 }));
123 return response.Status === 0 && addresses.length > 0
124 ? Effect.succeed(addresses)
125 : Effect.fail(new DnsNotReady({ hostname }));
126 }),
127 Effect.retry({
128 while: (error) => error._tag === "DnsNotReady",
129 schedule: Schedule.spaced("5 seconds"),
130 times: 10,
131 }),
132 Effect.timeout("60 seconds"),
133 );
134 const agent = yield* NodeHttpClient.makeAgent({
135 lookup: (requestedHost, options, callback) => {
136 if (requestedHost !== hostname) {
137 callback(new Error(`Unexpected domain lookup: ${requestedHost}`), "");
138 } else if (options.all) {
139 callback(null, addresses);
140 } else {
141 callback(null, addresses[0].address, 4);
142 }
143 },
144 });
145 // Keep the hostname in the URL so TLS certificate and SNI checks stay intact.
146 return yield* NodeHttpClient.makeNodeHttp.pipe(
147 Effect.provideService(NodeHttpClient.HttpAgent, agent),
148 );
149});
150
151class BodyMismatch extends Data.TaggedError("BodyMismatch")<{
152 url: string;
153 body: string;
154}> {
155 override get message() {
156 return `unexpected body from ${this.url}: '${this.body}'`;
157 }
158}
159
160/**
161 * Fetch `url` (optionally with request headers) and assert the response
162 * body satisfies `check`, retried through DNS/certificate/edge propagation
163 * on a freshly attached custom domain.
164 */
165const expectBody = Effect.fn(function* (
166 client: HttpClient.HttpClient,
167 url: string,
168 headers: Record<string, string>,
169 check: (body: string) => boolean,
170 retryDelay: Duration.Input = "1 second",
171) {
172 yield* client.get(url, { headers }).pipe(
173 Effect.flatMap((response) =>
174 response.text.pipe(
175 Effect.flatMap((body) =>
176 response.status === 200 && check(body)
177 ? Effect.void
178 : Effect.fail(
179 new BodyMismatch({ url, body: `${response.status}: ${body}` }),
180 ),
181 ),
182 ),
183 ),
184 Effect.timeout("5 seconds"),
185 Effect.retry({
186 schedule: Schedule.spaced(retryDelay),
187 times: 10,
188 }),
189 Effect.timeout("60 seconds"),
190 );
191});
192
193// These lifecycle tests mutate the same zone-level transform ruleset.
194describe
195 .skipIf(!!process.env.FAST)
196 .sequential(
197 "Cloudflare.Worker version affinity",
198 { tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"] },
199 () => {
200 test.provider(
201 "affinity rules converge across sources and clean up on destroy",
202 (stack) =>
203 Effect.gen(function* () {
204 const zone = yield* resolveZone;
205 const host = `wa-b-${suffix}.${zoneName}`;
206 const routeHost = `*.wa-rt-${suffix}.${zoneName}`;
207
208 yield* stack.destroy();
209
210 const deploy = (
211 affinity: Cloudflare.WorkerVersionAffinity | undefined,
212 ) =>
213 stack.deploy(
214 Effect.gen(function* () {
215 return yield* Cloudflare.Worker("AffinityWorker", {
216 script,
217 workersDev: false,
218 domain: host,
219 routes: [{ pattern: `${routeHost}/*` }],
220 version: { traffic: 50, affinity },
221 });
222 }),
223 );
224
225 // Sticky by session cookie, falling back to sticky IP: one rule
226 // per condition, scoped to this Worker's hostnames in the zone.
227 const v1 = yield* deploy({ cookie: "session_id", ip: true });
228 expect(v1.affinityZoneIds).toEqual([zone.id]);
229 const prefix = `alchemy:worker:${v1.workerName}:affinity`;
230 const hostExpr = `(http.host eq "${host}" or http.host wildcard "${routeHost}")`;
231 expect(yield* listAffinityRules(zone.id, v1.workerName)).toEqual([
232 {
233 description: `${prefix}:ip`,
234 expression: `${hostExpr} and not (len(http.request.cookies["session_id"]) > 0)`,
235 value: "to_string(ip.src)",
236 },
237 {
238 description: `${prefix}:key`,
239 expression: `${hostExpr} and len(http.request.cookies["session_id"]) > 0`,
240 value: `http.request.cookies["session_id"][0]`,
241 },
242 ]);
243
244 // The rule rewrites live zone traffic: the worker echoes the
245 // version-key header, so a request carrying the cookie echoes the
246 // cookie value and a bare request echoes the client IP.
247 const client = yield* domainClient(host);
248 yield* expectBody(
249 client,
250 `https://${host}`,
251 { cookie: "session_id=alchemy-test-key" },
252 (body) => body === "alchemy-test-key",
253 "5 seconds",
254 );
255 yield* expectBody(
256 client,
257 `https://${host}`,
258 {},
259 (body) => body !== "no-key" && /^[0-9a-fA-F.:]+$/.test(body),
260 );
261
262 // Switching the source converges in place: the header rule
263 // replaces the cookie rule and the IP fallback goes away.
264 const v2 = yield* deploy({ header: "X-User-Id" });
265 expect(v2.affinityZoneIds).toEqual([zone.id]);
266 expect(yield* listAffinityRules(zone.id, v2.workerName)).toEqual([
267 {
268 description: `${prefix}:key`,
269 expression: `${hostExpr} and len(http.request.headers["x-user-id"]) > 0`,
270 value: `http.request.headers["x-user-id"][0]`,
271 },
272 ]);
273
274 // Removing affinity removes the rules while the rollout continues.
275 const v3 = yield* deploy(undefined);
276 expect(v3.affinityZoneIds).toBeUndefined();
277 expect(yield* listAffinityRules(zone.id, v3.workerName)).toEqual(
278 [],
279 );
280
281 // Re-add, then destroy — teardown must remove the rules too.
282 const v4 = yield* deploy({ cookie: "session_id" });
283 expect(
284 yield* listAffinityRules(zone.id, v4.workerName),
285 ).toHaveLength(1);
286 yield* stack.destroy();
287 expect(yield* listAffinityRules(zone.id, v4.workerName)).toEqual(
288 [],
289 );
290 }).pipe(logLevel),
291 {
292 tags: ["provider:cloudflare:ruleset", "provider:cloudflare:zone"],
293 timeout: 600_000,
294 },
295 );
296
297 test.provider(
298 "rejects affinity on a workers.dev-only worker",
299 (stack) =>
300 Effect.gen(function* () {
301 yield* stack.destroy();
302
303 const error = yield* stack
304 .deploy(
305 Effect.gen(function* () {
306 return yield* Cloudflare.Worker("DevOnlyAffinity", {
307 script,
308 version: {
309 traffic: 50,
310 affinity: { cookie: "session_id" },
311 },
312 });
313 }),
314 )
315 .pipe(Effect.flip);
316
317 expect(error).toBeInstanceOf(WorkerVersionConfigError);
318 expect(String(error)).toContain("zone Transform Rule");
319
320 yield* stack.destroy();
321 }).pipe(logLevel),
322 { timeout: 180_000 },
323 );
324
325 test.provider(
326 "a canary version worker pins users on the parent's zone",
327 (stack) =>
328 Effect.gen(function* () {
329 const zone = yield* resolveZone;
330 const host = `wa-p-${suffix}.${zoneName}`;
331
332 yield* stack.destroy();
333
334 const parentWorker = (marker: string) =>
335 Cloudflare.Worker("AffinityParent", {
336 script: `export default { fetch() { return new Response("${marker}"); } };`,
337 workersDev: false,
338 domain: host,
339 });
340
341 // Parent + canary in one stack: the canary carries the affinity,
342 // and the rule lands on the parent's zone under the parent's name.
343 const v1 = yield* stack.deploy(
344 Effect.gen(function* () {
345 const parent = yield* parentWorker("parent-v1");
346 const canary = yield* Cloudflare.Worker("AffinityCanary", {
347 script,
348 version: {
349 parent,
350 traffic: 25,
351 affinity: { cookie: "session_id" },
352 },
353 });
354 return { parent, canary };
355 }),
356 );
357 expect(v1.canary.affinityZoneIds).toEqual([zone.id]);
358 const rules = yield* listAffinityRules(
359 zone.id,
360 v1.parent.workerName,
361 );
362 expect(rules).toHaveLength(1);
363 expect(rules[0].description).toEqual(
364 `alchemy:worker:${v1.parent.workerName}:affinity:key`,
365 );
366 expect(rules[0].expression).toEqual(
367 `http.host eq "${host}" and len(http.request.cookies["session_id"]) > 0`,
368 );
369
370 // Releasing the canary deletes the version resource — its delete
371 // must also clear the rules it owned on the parent's zone.
372 const v2 = yield* stack.deploy(
373 Effect.gen(function* () {
374 const parent = yield* parentWorker("parent-v1");
375 return { parent };
376 }),
377 );
378 expect(
379 yield* listAffinityRules(zone.id, v2.parent.workerName),
380 ).toEqual([]);
381
382 yield* stack.destroy();
383 }).pipe(logLevel),
384 {
385 tags: ["provider:cloudflare:ruleset", "provider:cloudflare:zone"],
386 timeout: 420_000,
387 },
388 );
389 },
390 );