1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import { WorkerVersionConfigError } from "@/Cloudflare/Workers/WorkerProvider.ts";
4import { State } from "@/State";
5import * as Test from "@/Test/Alchemy";
6import * as workers from "@distilled.cloud/cloudflare/workers";
7import { describe, expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11import * as pathe from "pathe";
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
25/**
26 * The most recent deployment of a script — Cloudflare returns deployments
27 * newest-first. Retried briefly: a deployment created moments ago can lag
28 * the list endpoint.
29 */
30const latestDeployment = Effect.fn(function* (scriptName: string) {
31 const { accountId } = yield* yield* CloudflareEnvironment;
32 const { deployments } = yield* workers.listScriptDeployments({
33 accountId,
34 scriptName,
35 });
36 return deployments[0];
37});
38
39describe.concurrent(
40 "Cloudflare.Worker version",
41 { tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"] },
42 () => {
43 for (const traffic of [undefined, 100] as const) {
44 test.provider(
45 `full deployment preserves annotations with traffic ${traffic ?? "omitted"}`,
46 (stack) =>
47 Effect.gen(function* () {
48 yield* stack.destroy();
49 const { accountId } = yield* yield* CloudflareEnvironment;
50
51 // Check both the initial upload and an update to the same script.
52 for (const tag of ["v1.0.0", "v1.0.1"]) {
53 const message = `Release ${tag}`;
54 const worker = yield* stack.deploy(
55 Cloudflare.Worker("AnnotatedWorker", {
56 script: script(tag),
57 version: { traffic, tag, message },
58 }),
59 );
60 const { deployment, version } = yield* Effect.gen(function* () {
61 const deployment = yield* latestDeployment(worker.workerName);
62 const version = yield* workers.getScriptScriptAndVersionSetting(
63 {
64 accountId,
65 scriptName: worker.workerName,
66 },
67 );
68 return { deployment, version };
69 }).pipe(
70 Effect.repeat({
71 schedule: Schedule.spaced("1 second"),
72 times: 10,
73 until: ({ version }) =>
74 version.annotations?.workersTag === tag &&
75 version.annotations?.workersMessage === message,
76 }),
77 );
78 expect(deployment?.versions).toHaveLength(1);
79 expect(deployment?.versions[0].percentage).toEqual(100);
80 expect(version.annotations?.workersTag).toEqual(tag);
81 expect(version.annotations?.workersMessage).toEqual(message);
82 }
83
84 yield* stack.destroy();
85 }).pipe(logLevel),
86 { timeout: 120_000 },
87 );
88 }
89
90 test.provider(
91 "preview version of a parent worker, promoted to a canary, then released",
92 (stack) =>
93 Effect.gen(function* () {
94 yield* stack.destroy();
95
96 // Deploy a parent worker and a zero-traffic preview version of it
97 // in one stack — `version.parent` takes the parent resource, which
98 // also orders the deploy (parent first) and the destroy (version
99 // released before the parent script is deleted).
100 const v1 = yield* stack.deploy(
101 Effect.gen(function* () {
102 const parent = yield* Cloudflare.Worker("VersionParent", {
103 script: script("parent-marker-v1"),
104 });
105 const preview = yield* Cloudflare.Worker("VersionPreview", {
106 script: script("preview-marker-v1"),
107 version: { parent, message: "alchemy preview test" },
108 });
109 return { parent, preview };
110 }),
111 );
112
113 // The preview owns no script — it is a version of the parent's.
114 expect(v1.preview.versionOf).toEqual(v1.parent.workerName);
115 expect(v1.preview.workerName).toEqual(v1.parent.workerName);
116 expect(v1.preview.versionId).toBeDefined();
117 // Zero traffic: no deployment was created.
118 expect(v1.preview.deploymentId).toBeUndefined();
119 // Primary URL is the *aliased* preview URL (stable across deploys);
120 // the per-version URL (`<version-prefix>-...`) rides in urls.
121 expect(v1.preview.versionAlias).toBeDefined();
122 expect(v1.preview.url).toEqual(
123 expect.stringContaining(
124 `https://${v1.preview.versionAlias}-${v1.parent.workerName}.`,
125 ),
126 );
127 expect(v1.preview.urls[1]).toMatch(
128 new RegExp(`^https://[0-9a-f]{8}-${v1.parent.workerName}\\.`),
129 );
130
131 // The parent's live deployment is untouched (100% on its own
132 // version, not the preview's).
133 const liveAfterPreview = yield* latestDeployment(
134 v1.parent.workerName,
135 );
136 expect(liveAfterPreview?.versions).toHaveLength(1);
137 expect(liveAfterPreview?.versions[0].percentage).toEqual(100);
138 expect(liveAfterPreview?.versions[0].versionId).not.toEqual(
139 v1.preview.versionId,
140 );
141
142 // Both URLs serve their own code.
143 yield* expectUrlContains(v1.parent.url!, "parent-marker-v1", {
144 label: "parent serves its own code",
145 });
146 yield* expectUrlContains(v1.preview.url!, "preview-marker-v1", {
147 label: "preview URL serves the version's code",
148 });
149
150 // Promote the version to a canary at 25% of the parent's traffic.
151 const v2 = yield* stack.deploy(
152 Effect.gen(function* () {
153 const parent = yield* Cloudflare.Worker("VersionParent", {
154 script: script("parent-marker-v1"),
155 });
156 const canary = yield* Cloudflare.Worker("VersionPreview", {
157 script: script("preview-marker-v1"),
158 version: { parent, traffic: 25 },
159 });
160 return { parent, canary };
161 }),
162 );
163
164 expect(v2.canary.versionId).toBeDefined();
165 expect(v2.canary.deploymentId).toBeDefined();
166 // The aliased preview URL is stable: the same alias re-points at
167 // the newly uploaded version.
168 expect(v2.canary.versionAlias).toEqual(v1.preview.versionAlias);
169 expect(v2.canary.url).toEqual(v1.preview.url);
170 const liveWithCanary = yield* latestDeployment(v2.parent.workerName);
171 expect(liveWithCanary?.id).toEqual(v2.canary.deploymentId);
172 expect(
173 liveWithCanary?.versions.map((v) => v.percentage).sort(),
174 ).toEqual([25, 75]);
175 expect(
176 liveWithCanary?.versions.find(
177 (v) => v.versionId === v2.canary.versionId,
178 )?.percentage,
179 ).toEqual(25);
180
181 // Remove the canary from the stack — delete restores 100% of
182 // traffic to the version that held the majority.
183 const v3 = yield* stack.deploy(
184 Effect.gen(function* () {
185 const parent = yield* Cloudflare.Worker("VersionParent", {
186 script: script("parent-marker-v1"),
187 });
188 return { parent };
189 }),
190 );
191
192 const liveAfterRelease = yield* latestDeployment(
193 v3.parent.workerName,
194 );
195 expect(liveAfterRelease?.versions).toHaveLength(1);
196 expect(liveAfterRelease?.versions[0].percentage).toEqual(100);
197 expect(liveAfterRelease?.versions[0].versionId).not.toEqual(
198 v2.canary.versionId,
199 );
200 yield* expectUrlContains(v3.parent.url!, "parent-marker-v1", {
201 label: "parent still serves its own code after canary release",
202 });
203
204 yield* stack.destroy();
205 const { accountId } = yield* yield* CloudflareEnvironment;
206 yield* waitForWorkerToBeDeleted(v3.parent.workerName, accountId);
207 }).pipe(logLevel),
208 { timeout: 300_000 },
209 );
210
211 test.provider(
212 "preview version carries its own static assets",
213 (stack) =>
214 Effect.gen(function* () {
215 yield* stack.destroy();
216
217 const { parent, preview } = yield* stack.deploy(
218 Effect.gen(function* () {
219 const parent = yield* Cloudflare.Worker("AssetsVersionParent", {
220 script: script("assets-parent-marker"),
221 });
222 const preview = yield* Cloudflare.Worker("AssetsVersionPreview", {
223 script:
224 "export default { fetch(request, env) { return env.ASSETS.fetch(request); } };",
225 assets: {
226 directory: pathe.resolve(import.meta.dirname, "assets"),
227 runWorkerFirst: true,
228 },
229 version: { parent },
230 });
231 return { parent, preview };
232 }),
233 );
234
235 expect(preview.versionOf).toEqual(parent.workerName);
236 expect(preview.hash?.assets).toBeDefined();
237 yield* expectUrlContains(
238 new URL("/test.txt", preview.url!).toString(),
239 "This is a test file from assets.",
240 { label: "preview URL serves the version's static assets" },
241 );
242 yield* expectUrlContains(parent.url!, "assets-parent-marker", {
243 label: "preview assets leave the parent deployment untouched",
244 });
245
246 yield* stack.destroy();
247 const { accountId } = yield* yield* CloudflareEnvironment;
248 yield* waitForWorkerToBeDeleted(parent.workerName, accountId);
249 }).pipe(logLevel),
250 { timeout: 240_000 },
251 );
252
253 test.provider(
254 "version.parent accepts a literal script name",
255 (stack) =>
256 Effect.gen(function* () {
257 yield* stack.destroy();
258
259 const { parent } = yield* stack.deploy(
260 Effect.gen(function* () {
261 const parent = yield* Cloudflare.Worker("StringParent", {
262 script: script("string-parent-v1"),
263 });
264 return { parent };
265 }),
266 );
267
268 // Reference the parent by its physical script name — the escape
269 // hatch for scripts not managed in any reachable stack state.
270 const parentName = parent.workerName;
271 const { preview } = yield* stack.deploy(
272 Effect.gen(function* () {
273 yield* Cloudflare.Worker("StringParent", {
274 script: script("string-parent-v1"),
275 });
276 const preview = yield* Cloudflare.Worker("StringPreview", {
277 script: script("string-preview-v1"),
278 version: { parent: parentName },
279 });
280 return { preview };
281 }),
282 );
283
284 expect(preview.versionOf).toEqual(parentName);
285 yield* expectUrlContains(preview.url!, "string-preview-v1", {
286 label: "string-parent preview URL serves the version's code",
287 });
288
289 yield* stack.destroy();
290 }).pipe(logLevel),
291 { timeout: 240_000 },
292 );
293
294 test.provider(
295 "gradual rollout splits traffic between a worker's own versions",
296 (stack) =>
297 Effect.gen(function* () {
298 yield* stack.destroy();
299
300 // First deploy: version.traffic is set, but there is no previous
301 // live version to split against — deploys at 100%.
302 const v1 = yield* stack.deploy(
303 Effect.gen(function* () {
304 return yield* Cloudflare.Worker("RolloutWorker", {
305 script: script("rollout-v1"),
306 version: { traffic: 50 },
307 });
308 }),
309 );
310 const liveV1 = yield* latestDeployment(v1.workerName);
311 expect(liveV1?.versions).toHaveLength(1);
312 expect(liveV1?.versions[0].percentage).toEqual(100);
313 yield* expectUrlContains(v1.url!, "rollout-v1", {
314 label: "first deploy serves at 100%",
315 });
316
317 // Second deploy with new code at 50%: the new version and the
318 // previously-live version split the traffic.
319 const v2 = yield* stack.deploy(
320 Effect.gen(function* () {
321 return yield* Cloudflare.Worker("RolloutWorker", {
322 script: script("rollout-v2"),
323 version: { traffic: 50, message: "canary rollout" },
324 });
325 }),
326 );
327 expect(v2.versionId).toBeDefined();
328 expect(v2.deploymentId).toBeDefined();
329 const liveV2 = yield* latestDeployment(v2.workerName);
330 expect(liveV2?.id).toEqual(v2.deploymentId);
331 expect(liveV2?.versions.map((v) => v.percentage).sort()).toEqual([
332 50, 50,
333 ]);
334 expect(
335 liveV2?.versions.some((v) => v.versionId === v2.versionId),
336 ).toBe(true);
337 // urls ordering during a rollout: the stable workers.dev URL stays
338 // primary; the uploaded version's preview URL trails.
339 expect(v2.urls[0]).toMatch(
340 new RegExp(`^https://${v2.workerName}\\..*\\.workers\\.dev$`),
341 );
342 expect(v2.urls[v2.urls.length - 1]).toMatch(
343 new RegExp(
344 `^https://${v2.versionId!.split("-")[0]}-${v2.workerName}\\..*\\.workers\\.dev$`,
345 ),
346 );
347
348 // Promote: traffic back to the default full cutover.
349 const v3 = yield* stack.deploy(
350 Effect.gen(function* () {
351 return yield* Cloudflare.Worker("RolloutWorker", {
352 script: script("rollout-v3"),
353 });
354 }),
355 );
356 const liveV3 = yield* latestDeployment(v3.workerName);
357 expect(liveV3?.versions).toHaveLength(1);
358 expect(liveV3?.versions[0].percentage).toEqual(100);
359 yield* expectUrlContains(v3.url!, "rollout-v3", {
360 label: "full deploy serves the new version everywhere",
361 });
362
363 yield* stack.destroy();
364 }).pipe(logLevel),
365 { timeout: 300_000 },
366 );
367
368 test.provider(
369 "Worker.URL on a version worker resolves to the aliased preview URL",
370 (stack) =>
371 Effect.gen(function* () {
372 yield* stack.destroy();
373
374 const { preview } = yield* stack.deploy(
375 Effect.gen(function* () {
376 const parent = yield* Cloudflare.Worker("SelfUrlParent", {
377 script: script("self-url-parent-v1"),
378 });
379 // Async version worker binding its own URL via env — the
380 // self_url sentinel must lower into the aliased preview URL,
381 // which is known before the version exists.
382 const preview = yield* Cloudflare.Worker("SelfUrlPreview", {
383 script: `export default { fetch(request, env) { return new Response(env.PUBLIC_URL); } };`,
384 env: { PUBLIC_URL: Cloudflare.Worker.URL },
385 version: { parent },
386 });
387 return { preview };
388 }),
389 );
390
391 expect(preview.versionAlias).toBeDefined();
392 expect(preview.url).toEqual(
393 expect.stringContaining(`https://${preview.versionAlias}-`),
394 );
395 // The deployed version reports its own aliased preview URL.
396 yield* expectUrlContains(preview.url!, preview.url!, {
397 label: "version's PUBLIC_URL equals its aliased preview URL",
398 });
399
400 yield* stack.destroy();
401 }).pipe(logLevel),
402 { timeout: 240_000 },
403 );
404
405 // State-migration: version workers deployed by the pre-url-redesign
406 // provider (#948) persisted their preview URLs in a `domains` list with
407 // no `urls`/`domain` attributes, and a metadata hash computed over the
408 // old url/subdomain surface. A props-identical redeploy must migrate the
409 // record as a one-time update and settle back to noop.
410 test.provider(
411 "props-identical redeploy migrates pre-redesign version-worker state",
412 (stack) =>
413 Effect.gen(function* () {
414 yield* stack.destroy();
415
416 const program = Effect.gen(function* () {
417 const parent = yield* Cloudflare.Worker("MigrParent", {
418 script: script("migr-parent-v1"),
419 });
420 const preview = yield* Cloudflare.Worker("MigrPreview", {
421 script: script("migr-preview-v1"),
422 version: { parent },
423 });
424 return { parent, preview };
425 });
426
427 const actionOf = (plan: any, logicalId: string) =>
428 (Object.values(plan.resources) as any[]).find(
429 (node: any) => node.resource.LogicalId === logicalId,
430 )?.action;
431
432 const v1 = yield* stack.deploy(program);
433 expect(v1.preview.urls).toHaveLength(2);
434
435 // Rewrite the version worker's record to the #948-era shape:
436 // preview URLs in `domains`, no `urls`/`domain`, legacy hash.
437 yield* Effect.gen(function* () {
438 const state = yield* yield* State;
439 const key = {
440 stack: stack.name,
441 stage: stack.stage,
442 fqn: "MigrPreview",
443 };
444 const current = yield* state.get(key);
445 expect(current).toBeDefined();
446 const attr = {
447 ...(current as any).attr,
448 domains: [...(current as any).attr.urls],
449 hash: { ...(current as any).attr.hash, metadata: "legacy" },
450 };
451 delete attr.urls;
452 delete attr.domain;
453 yield* state.set({ ...key, value: { ...(current as any), attr } });
454 }).pipe(Effect.provide(stack.state));
455
456 // Identical props still plan as an update, driven by the metadata
457 // hash surface change alone.
458 const migrationPlan = yield* stack.plan(program);
459 expect(actionOf(migrationPlan, "MigrPreview")).toBe("update");
460
461 const v2 = yield* stack.deploy(program);
462 // The aliased preview URL is deterministic, so it survives the
463 // migration byte-for-byte; the per-version URL is re-minted by the
464 // migration's version upload.
465 expect(v2.preview.urls[0]).toEqual(v1.preview.urls[0]);
466 expect(v2.preview.urls[1]).toMatch(
467 new RegExp(`^https://[0-9a-f]{8}-${v2.parent.workerName}\\.`),
468 );
469 expect(v2.preview.domain).toBeUndefined();
470
471 // One-time: the same program now settles as a full noop.
472 const settledPlan = yield* stack.plan(program);
473 expect(actionOf(settledPlan, "MigrParent")).toBe("noop");
474 expect(actionOf(settledPlan, "MigrPreview")).toBe("noop");
475
476 yield* stack.destroy();
477 }).pipe(logLevel),
478 { timeout: 300_000 },
479 );
480
481 test.provider(
482 "rejects script-level settings on a version worker",
483 (stack) =>
484 Effect.gen(function* () {
485 yield* stack.destroy();
486
487 const { parent } = yield* stack.deploy(
488 Effect.gen(function* () {
489 const parent = yield* Cloudflare.Worker("InvalidParent", {
490 script: script("invalid-parent-v1"),
491 });
492 return { parent };
493 }),
494 );
495
496 const error = yield* stack
497 .deploy(
498 Effect.gen(function* () {
499 yield* Cloudflare.Worker("InvalidParent", {
500 script: script("invalid-parent-v1"),
501 });
502 return yield* Cloudflare.Worker("InvalidPreview", {
503 script: script("invalid-preview-v1"),
504 version: { parent: parent.workerName },
505 crons: ["*/5 * * * *"],
506 });
507 }),
508 )
509 .pipe(Effect.flip);
510
511 expect(error).toBeInstanceOf(WorkerVersionConfigError);
512 expect(String(error)).toContain("script-level settings");
513
514 yield* stack.destroy();
515 }).pipe(logLevel),
516 { timeout: 180_000 },
517 );
518 },
519);