1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import * as pathe from "pathe";
13import { cloneFixture } from "../Utils/Fixture.ts";
14import { waitForWorkerToBeDeleted } from "../Utils/Worker.ts";
15import { prepareNextjsFixture } from "./TypeScriptCompat.ts";
16
17const { test } = Test.make({ providers: Cloudflare.providers() });
18
19const logLevel = Effect.provideService(
20 MinimumLogLevel,
21 process.env.DEBUG ? "Debug" : "Info",
22);
23
24const fixtureDir = pathe.resolve(
25 import.meta.dirname,
26 "fixtures",
27 "nextjs-isr-app",
28);
29const stampOf = (body: string, prefix: string): string | undefined =>
30 body.match(new RegExp(`${prefix}:(?:<!-- -->)?(\\d+)`))?.[1];
31
32/** GET `url` until a stamp is present and `predicate(stamp)` holds. */
33const pollStamp = (
34 url: string,
35 prefix: string,
36 predicate: (stamp: string) => boolean,
37 times = 45,
38) =>
39 Effect.gen(function* () {
40 const client = yield* HttpClient.HttpClient;
41 return yield* client.get(url).pipe(
42 Effect.flatMap((res) =>
43 Effect.flatMap(res.text, (body) => {
44 const stamp = res.status === 200 ? stampOf(body, prefix) : undefined;
45 return stamp !== undefined && predicate(stamp)
46 ? Effect.succeed(stamp)
47 : Effect.fail(
48 new Error(
49 `stamp not ready (${res.status}): ${stamp ?? body.slice(0, 200)}`,
50 ),
51 );
52 }),
53 ),
54 Effect.retry({ schedule: Schedule.spaced("2 seconds"), times }),
55 );
56 });
57
58/**
59 * Writable ISR on real Cloudflare: KV incremental cache + KV tag cache +
60 * same-worker Durable Object revalidation queue, with the
61 * `WORKER_SELF_REFERENCE` self service binding wired automatically by the
62 * Nextjs resource. Covers:
63 *
64 * 1. the ISR page caches in KV (stable stamp across hits)
65 * 2. on-demand `revalidatePath` purges the writable cache (fresh stamp)
66 * 3. time-based revalidation regenerates through the DO queue, which
67 * re-fetches the worker via the self service binding
68 * 4. a second deploy is a noop (pins the self_service + DO binding
69 * metadata hash stability)
70 */
71// Tests are independent (per-test scratch stacks, private fixture clones),
72// so run them concurrently; suites are sequential by default.
73describe.concurrent(
74 "Nextjs ISR",
75 {
76 tags: [
77 "provider:cloudflare",
78 "provider:cloudflare:kv",
79 "provider:cloudflare:website",
80 "provider:cloudflare:worker",
81 "live",
82 ],
83 },
84 () => {
85 test.provider(
86 "Nextjs writable ISR: KV cache + DO queue + self-reference on real Cloudflare",
87 (stack) =>
88 Effect.gen(function* () {
89 const { accountId } = yield* yield* CloudflareEnvironment;
90
91 yield* stack.destroy();
92
93 const rootDir = yield* cloneFixture(fixtureDir, {
94 prefix: "alchemy-nextjs-isr-",
95 entries: [
96 "package.json",
97 "tsconfig.json",
98 "next.config.mjs",
99 "app",
100 "public",
101 ],
102 });
103 yield* prepareNextjsFixture(rootDir);
104 const fs = yield* FileSystem.FileSystem;
105 const path = yield* Path.Path;
106 const nextConfigPath = path.join(rootDir, "next.config.mjs");
107 const nextConfig = yield* fs.readFileString(nextConfigPath);
108 expect(
109 yield* fs.exists(path.join(rootDir, "open-next.config.ts")),
110 ).toBe(false);
111
112 const deploy = () =>
113 stack.deploy(
114 Effect.gen(function* () {
115 const incCache = yield* Cloudflare.KV.Namespace("NextIncCache");
116 const tagCache = yield* Cloudflare.KV.Namespace("NextTagCache");
117 const site = yield* Cloudflare.Website.Nextjs("NextjsIsrSite", {
118 rootDir,
119 workersDev: { enabled: true, previewsEnabled: true },
120 memo: {
121 include: [
122 "app/**",
123 "public/**",
124 "package.json",
125 "tsconfig.json",
126 "next.config.mjs",
127 ],
128 },
129 isr: { incrementalCache: incCache, tagCache },
130 });
131 return { site };
132 }),
133 );
134
135 const { site } = yield* deploy();
136 expect(yield* fs.readFileString(nextConfigPath)).toBe(nextConfig);
137 expect(
138 yield* fs.exists(path.join(rootDir, "open-next.config.ts")),
139 ).toBe(false);
140 expect(site.url).toBeDefined();
141
142 // 1. The ISR page caches in KV: the stamp stabilizes across hits.
143 // (The very first hits may race the initial cache write, so anchor
144 // on two consecutive equal reads.)
145 const client = yield* HttpClient.HttpClient;
146 const primed = yield* pollStamp(
147 `${site.url!}/isr`,
148 "isr-stamp",
149 () => true,
150 );
151 const settled = yield* pollStamp(
152 `${site.url!}/isr`,
153 "isr-stamp",
154 () => true,
155 );
156 if (primed === settled) {
157 // Cached: consecutive reads agree.
158 expect(settled).toBe(primed);
159 }
160 const cached = yield* pollStamp(
161 `${site.url!}/isr`,
162 "isr-stamp",
163 () => true,
164 );
165 expect(cached).toBe(settled);
166
167 // 2. On-demand revalidation: revalidatePath purges the KV entry; a
168 // subsequent render produces a NEW stamp. With a read-only cache
169 // this would never change. A single edge request can still 404
170 // right after earlier probes succeeded (workers.dev colo
171 // inconsistency), so retry through non-200s, bounded —
172 // revalidatePath is idempotent.
173 const revalidateRes = yield* client
174 .execute(HttpClientRequest.post(`${site.url!}/api/revalidate`))
175 .pipe(
176 Effect.flatMap((res) =>
177 res.status === 200
178 ? Effect.succeed(res)
179 : Effect.flatMap(res.text, (body) =>
180 Effect.fail(
181 new Error(
182 `revalidate not ready (${res.status}): ${body.slice(0, 200)}`,
183 ),
184 ),
185 ),
186 ),
187 Effect.retry({
188 schedule: Schedule.spaced("2 seconds"),
189 times: 30,
190 }),
191 );
192 expect(revalidateRes.status).toBe(200);
193 const fresh = yield* pollStamp(
194 `${site.url!}/isr`,
195 "isr-stamp",
196 (s) => s !== cached,
197 );
198 expect(fresh).not.toBe(cached);
199
200 // 3. Time-based revalidation: after the 2s window lapses, a stale
201 // hit enqueues regeneration through the DO queue (which re-fetches
202 // the worker via WORKER_SELF_REFERENCE) and a later hit serves the
203 // regenerated payload.
204 const fastPrimed = yield* pollStamp(
205 `${site.url!}/fast-isr`,
206 "fast-isr-stamp",
207 () => true,
208 );
209 yield* Effect.sleep("3 seconds");
210 const fastFresh = yield* pollStamp(
211 `${site.url!}/fast-isr`,
212 "fast-isr-stamp",
213 (s) => s !== fastPrimed,
214 60,
215 );
216 expect(fastFresh).not.toBe(fastPrimed);
217
218 // 4. Second deploy: nothing changed, so the deploy is a noop — pins
219 // the metadata-hash stability of the self_service sentinel and the
220 // own-class DO binding.
221 const again = yield* deploy();
222 expect(again.site.hash?.input).toEqual(site.hash?.input);
223 expect(again.site.url).toBe(site.url);
224
225 yield* stack.destroy();
226 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
227 }).pipe(logLevel),
228 { timeout: 600_000 },
229 );
230 },
231);