1import * as Cloudflare from "@/Cloudflare";
2import * as Test from "@/Test/Alchemy";
3import { expect } from "alchemy-test";
4import * as Effect from "effect/Effect";
5import { MinimumLogLevel } from "effect/References";
6import * as Schedule from "effect/Schedule";
7import { expectUrlContains } from "../Utils/Http.ts";
8import CacheTestWorker from "./fixtures/cache/cache-worker.ts";
9
10const { test } = Test.make({ providers: Cloudflare.providers() });
11
12const logLevel = Effect.provideService(
13 MinimumLogLevel,
14 process.env.DEBUG ? "Debug" : "Info",
15);
16
17// The worker stamps every invocation with a fresh UUID, so two fetches of
18// the same URL returning the same body means the second response was served
19// from the Workers Cache without invoking the worker.
20const cachedScript = `export default {
21 async fetch() {
22 return new Response("id:" + crypto.randomUUID(), {
23 headers: { "Cache-Control": "public, max-age=300" },
24 });
25 },
26};
27`;
28
29const fetchCached = (url: string) =>
30 Effect.tryPromise(async (signal) => {
31 // NO cache-busting here — the whole point is hitting the same cache key.
32 const res = await fetch(url, { signal });
33 return {
34 status: res.status,
35 cacheStatus: res.headers.get("cf-cache-status"),
36 body: await res.text(),
37 };
38 });
39
40// Poll `url` until two consecutive responses carry the same worker-stamped
41// body ("id:<uuid>"), proving the second was served from the Workers Cache
42// without invoking the worker. Only marker-carrying bodies participate:
43// workers.dev placeholder pages also serve identical 200 bodies, so an
44// unanchored comparison would count two placeholders as a false "hit".
45// Bounded so a broken cache fails fast instead of hitting the vitest
46// timeout.
47const pollForCacheHit = (url: string) => {
48 let previous: string | undefined;
49 return fetchCached(url).pipe(
50 Effect.map((res) => {
51 const isWorkerBody = res.status === 200 && res.body.startsWith("id:");
52 const isHit =
53 (isWorkerBody && previous !== undefined && res.body === previous) ||
54 res.cacheStatus === "HIT";
55 if (isWorkerBody) {
56 previous = res.body;
57 }
58 return { ...res, isHit };
59 }),
60 Effect.repeat({
61 schedule: Schedule.spaced("2 seconds"),
62 until: (res) => res.isHit,
63 times: 20,
64 }),
65 );
66};
67
68test.provider(
69 "worker with Workers Cache enabled serves repeat requests from cache",
70 (stack) =>
71 Effect.gen(function* () {
72 yield* stack.destroy();
73
74 const { worker } = yield* stack.deploy(
75 Effect.gen(function* () {
76 return {
77 worker: yield* Cloudflare.Worker("CachedWorker", {
78 script: cachedScript,
79 cache: {
80 enabled: true,
81 crossVersionCache: true,
82 },
83 }),
84 };
85 }),
86 );
87 expect(worker.url).toBeDefined();
88
89 // Ride out workers.dev propagation before probing cache behavior.
90 yield* expectUrlContains(worker.url!, "id:", {
91 label: "cached worker propagation",
92 });
93
94 const hit = yield* pollForCacheHit(`${worker.url}/cached`);
95 expect(hit.isHit).toBe(true);
96
97 // Metadata-only update: same script, cache turned off. The metadata
98 // hash must register the change and the API must accept the update.
99 const disabled = yield* stack.deploy(
100 Effect.gen(function* () {
101 return {
102 worker: yield* Cloudflare.Worker("CachedWorker", {
103 script: cachedScript,
104 cache: {
105 enabled: false,
106 },
107 }),
108 };
109 }),
110 );
111 expect(disabled.worker.workerName).toEqual(worker.workerName);
112
113 yield* stack.destroy();
114 }).pipe(logLevel),
115 {
116 tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"],
117 timeout: 240_000,
118 },
119);
120
121// Effect-native path: `yield* Cloudflare.cache()` in the init phase enables
122// Workers Cache via the binding contract (no `cache` prop) and returns the
123// runtime purge client backed by `ctx.cache`.
124test.provider(
125 "Cloudflare.cache() enables the cache and purges by tag",
126 (stack) =>
127 Effect.gen(function* () {
128 yield* stack.destroy();
129
130 const { worker } = yield* stack.deploy(
131 Effect.gen(function* () {
132 return { worker: yield* CacheTestWorker };
133 }),
134 );
135 expect(worker.url).toBeDefined();
136
137 yield* expectUrlContains(`${worker.url}/item`, "id:", {
138 label: "cache worker propagation",
139 });
140
141 // Poll the same URL until a repeat body proves the binding actually
142 // enabled the cache.
143 const url = `${worker.url}/item`;
144 const hit = yield* pollForCacheHit(url);
145 expect(hit.isHit).toBe(true);
146 const cachedBody = hit.body;
147
148 // Purge by Cache-Tag through the runtime client. Retried until the
149 // response parses as JSON — transient placeholders/5xx bodies throw
150 // and retry; a parsed `success: false` is a genuine failure and
151 // surfaces via the assertion.
152 const purged = yield* Effect.sync(
153 () => `${worker.url}/purge?cb=${Date.now()}`,
154 ).pipe(
155 Effect.flatMap(fetchCached),
156 Effect.flatMap((res) =>
157 res.status === 200
158 ? Effect.try(() => JSON.parse(res.body) as { success: boolean })
159 : Effect.fail(new Error(`Purge worker not ready: ${res.status}`)),
160 ),
161 Effect.retry({
162 schedule: Schedule.spaced("2 seconds"),
163 times: 10,
164 }),
165 );
166 expect(purged.success).toBe(true);
167
168 // A fresh invocation id proves the tag purge evicted the entry. The
169 // marker check keeps a placeholder page (also ≠ cachedBody) from
170 // passing as "fresh".
171 const fresh = yield* fetchCached(url).pipe(
172 Effect.repeat({
173 schedule: Schedule.spaced("2 seconds"),
174 until: (res) =>
175 res.status === 200 &&
176 res.body.startsWith("id:") &&
177 res.body !== cachedBody,
178 times: 20,
179 }),
180 );
181 expect(fresh.body).not.toBe(cachedBody);
182 expect(fresh.body.startsWith("id:")).toBe(true);
183
184 yield* stack.destroy();
185 }).pipe(logLevel),
186 {
187 tags: [
188 "provider:cloudflare",
189 "provider:cloudflare:cache",
190 "provider:cloudflare:worker",
191 "live",
192 ],
193 timeout: 240_000,
194 },
195);