1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import { isLocalId } from "@/Cloudflare/LocalRuntime";
4import * as Alchemy from "@/index.ts";
5import * as Test from "@/Test/Alchemy";
6import * as kv from "@distilled.cloud/cloudflare/kv";
7import { describe, expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Path from "effect/Path";
11import { MinimumLogLevel } from "effect/References";
12import * as Schedule from "effect/Schedule";
13import * as Stream from "effect/Stream";
14import * as HttpClient from "effect/http/HttpClient";
15import * as HttpClientRequest from "effect/http/HttpClientRequest";
16import * as pathe from "pathe";
17import { cloneFixture } from "../Utils/Fixture.ts";
18import { expectUrlContains } from "../Utils/Http.ts";
19import { prepareNextjsFixture } from "./TypeScriptCompat.ts";
20
21const { test } = Test.make({ providers: Cloudflare.providers(), dev: true });
22
23const logLevel = Effect.provideService(
24 MinimumLogLevel,
25 process.env.DEBUG ? "Debug" : "Info",
26);
27
28const fixtureDir = pathe.resolve(import.meta.dirname, "fixtures", "nextjs-app");
29
30const fixtureEntries = [
31 "package.json",
32 "tsconfig.json",
33 "next.config.mjs",
34 "middleware.ts",
35 "app",
36 "pages",
37 "public",
38];
39
40const memoInclude = [
41 "app/**",
42 "pages/**",
43 "public/**",
44 "package.json",
45 "tsconfig.json",
46 "middleware.ts",
47 "next.config.mjs",
48];
49
50/** GET `url` until it answers 200 with a JSON body — bounded (~60s). */
51const fetchJsonReady = <T>(url: string) =>
52 Effect.gen(function* () {
53 const client = yield* HttpClient.HttpClient;
54 return yield* client.get(url).pipe(
55 Effect.flatMap((res) =>
56 Effect.flatMap(res.text, (body) =>
57 res.status === 200
58 ? Effect.try({
59 try: () => JSON.parse(body) as T,
60 catch: () => new Error(`non-json body: ${body}`),
61 })
62 : Effect.fail(
63 new Error(
64 `Worker not ready (${res.status}): ${body.slice(0, 300)}`,
65 ),
66 ),
67 ),
68 ),
69 Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 30 }),
70 );
71 });
72
73/** PUT a JSON body to the fixture's /api/kv route — bounded (~60s). */
74const putKv = (base: string, key: string, value: string) =>
75 HttpClient.execute(
76 HttpClientRequest.put(`${base}/api/kv`).pipe(
77 HttpClientRequest.bodyJsonUnsafe({ key, value }),
78 ),
79 ).pipe(
80 Effect.flatMap((res) =>
81 res.status === 200
82 ? res.json
83 : Effect.flatMap(res.text, (body) =>
84 Effect.fail(
85 new Error(
86 `kv put not ready (${res.status}): ${body.slice(0, 300)}`,
87 ),
88 ),
89 ),
90 ),
91 Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 30 }),
92 );
93
94// ─────────────────────────────────────────────────────────────────────
95// Preview mode (the default): the OpenNext worker is built and served
96// under workerd locally — production parity, no HMR.
97// ─────────────────────────────────────────────────────────────────────
98
99// Tests are independent (per-test scratch stacks, private fixture clones),
100// so run them concurrently; suites are sequential by default.
101describe.concurrent(
102 "Nextjs dev",
103 { tags: ["provider:cloudflare", "provider:cloudflare:website"] },
104 () => {
105 test.provider(
106 "Nextjs dev (preview): OpenNext worker under local workerd with emulated bindings",
107 (stack) =>
108 Effect.gen(function* () {
109 yield* stack.destroy();
110
111 // Clone outside the repo so Next does not treat the alchemy
112 // monorepo as the workspace root (that lookup hits typescript 7
113 // / tsgo, which has no `lib/typescript.js`).
114 const rootDir = yield* cloneFixture(fixtureDir, {
115 prefix: "alchemy-nextjs-dev-",
116 entries: fixtureEntries,
117 });
118 yield* prepareNextjsFixture(rootDir);
119
120 const bindingMarker = "nextjs-dev-binding-marker";
121
122 const deployed = yield* stack.deploy(
123 Effect.gen(function* () {
124 const siteKv = yield* Cloudflare.KV.Namespace("NextjsDevKV");
125 const site = yield* Cloudflare.Website.Nextjs("NextjsLocal", {
126 rootDir,
127 dev: { port: 0 },
128 memo: { include: memoInclude },
129 env: {
130 TEST_TEXT: bindingMarker,
131 FIXTURE_KV: siteKv,
132 },
133 });
134 return { site, siteKv };
135 }),
136 );
137 const site = deployed.site;
138
139 // Local identity: the url points at the alchemy dev proxy — no cloud
140 // Worker exists — and the KV namespace is emulated (`dev:` id, proof
141 // no cloud API call ran).
142 expect(site.url).toBeDefined();
143 expect(site.url).toMatch(/^http:\/\/localhost:\d+/);
144 expect(isLocalId(deployed.siteKv.namespaceId)).toBe(true);
145
146 // The SSR page rendered by the OpenNext worker under workerd.
147 yield* expectUrlContains(`${site.url!}/`, "NEXTJS_SSR_MARKER", {
148 timeout: "120 seconds",
149 label: "nextjs dev SSR home page",
150 });
151
152 // Binding read through OpenNext's getCloudflareContext().
153 const binding = yield* fetchJsonReady<{ value: string | null }>(
154 `${site.url!}/api/binding`,
155 );
156 expect(binding.value).toBe(bindingMarker);
157
158 // Prerendered ISR page serves from the read-only static-assets cache.
159 yield* expectUrlContains(`${site.url!}/isr`, "NEXTJS_ISR_MARKER", {
160 timeout: "60 seconds",
161 label: "nextjs dev prerendered ISR page",
162 });
163
164 // Static asset from public/.
165 yield* expectUrlContains(
166 `${site.url!}/static.txt`,
167 "NEXTJS_STATIC_ASSET_MARKER",
168 { timeout: "60 seconds", label: "nextjs dev static asset" },
169 );
170
171 // KV round-trip against the local simulator through the worker.
172 yield* putKv(site.url!, "dev-key", "dev-value");
173 const got = yield* fetchJsonReady<{ value: string | null }>(
174 `${site.url!}/api/kv?key=dev-key`,
175 );
176 expect(got.value).toBe("dev-value");
177
178 yield* stack.destroy();
179 }).pipe(logLevel),
180 { tags: ["provider:cloudflare:kv", "local"], timeout: 420_000 },
181 );
182
183 // ─────────────────────────────────────────────────────────────────────
184 // HMR mode: the real `next dev` (Turbopack) in Node, bindings proxied
185 // onto OpenNext's getCloudflareContext() contract.
186 // ─────────────────────────────────────────────────────────────────────
187
188 test.provider(
189 "Nextjs dev (hmr): real next dev serves edits without redeploying",
190 (stack) =>
191 Effect.gen(function* () {
192 const fs = yield* FileSystem.FileSystem;
193 const path = yield* Path.Path;
194
195 yield* stack.destroy();
196
197 const rootDir = yield* cloneFixture(fixtureDir, {
198 prefix: "alchemy-nextjs-dev-hmr-",
199 entries: fixtureEntries,
200 });
201 yield* prepareNextjsFixture(rootDir);
202
203 const bindingMarker = "nextjs-hmr-binding-marker";
204
205 const deployed = yield* stack.deploy(
206 Effect.gen(function* () {
207 const site = yield* Cloudflare.Website.Nextjs("NextjsHmrLocal", {
208 rootDir,
209 dev: { mode: "hmr", port: 0 },
210 memo: { include: memoInclude },
211 env: {
212 TEST_TEXT: bindingMarker,
213 },
214 });
215 return { site };
216 }),
217 );
218 const site = deployed.site;
219
220 expect(site.url).toMatch(/^http:\/\/localhost:\d+/);
221
222 // The SSR page through the real `next dev` (Turbopack).
223 yield* expectUrlContains(`${site.url!}/`, "NEXTJS_SSR_MARKER", {
224 timeout: "180 seconds",
225 label: "nextjs hmr dev SSR home page",
226 });
227
228 // getCloudflareContext().env binding through the platform proxy.
229 const binding = yield* fetchJsonReady<{ value: string | null }>(
230 `${site.url!}/api/binding`,
231 );
232 expect(binding.value).toBe(bindingMarker);
233
234 // ── HMR: edit the page in place. The stack is NOT re-applied —
235 // Turbopack must recompile and serve the new marker through the
236 // same alchemy proxy ─────────────────────────────────────────────
237 const pagePath = path.join(rootDir, "app", "page.tsx");
238 const page = yield* fs.readFileString(pagePath);
239 yield* fs.writeFileString(
240 pagePath,
241 page.replace("NEXTJS_SSR_MARKER", "NEXTJS_SSR_MARKER_HMR"),
242 );
243 yield* expectUrlContains(`${site.url!}/`, "NEXTJS_SSR_MARKER_HMR", {
244 timeout: "120 seconds",
245 label: "nextjs hmr dev page after edit",
246 });
247
248 // The binding bridge survived the recompile.
249 const still = yield* fetchJsonReady<{ value: string | null }>(
250 `${site.url!}/api/binding`,
251 );
252 expect(still.value).toBe(bindingMarker);
253
254 yield* stack.destroy();
255 }).pipe(logLevel),
256 { tags: ["local"], timeout: 420_000 },
257 );
258
259 // ─────────────────────────────────────────────────────────────────────
260 // Alchemy.remote(): the KV namespace opts OUT of local emulation — real
261 // cloud namespace behind the locally-served site.
262 // ─────────────────────────────────────────────────────────────────────
263
264 test.provider(
265 "Nextjs dev: Alchemy.remote() KV namespace runs live behind the dev server",
266 (stack) =>
267 Effect.gen(function* () {
268 yield* stack.destroy();
269
270 const rootDir = yield* cloneFixture(fixtureDir, {
271 prefix: "alchemy-nextjs-dev-remote-",
272 entries: fixtureEntries,
273 });
274 yield* prepareNextjsFixture(rootDir);
275
276 const deployed = yield* stack.deploy(
277 Effect.gen(function* () {
278 const siteKv = yield* Cloudflare.KV.Namespace(
279 "NextjsDevRemoteKV",
280 ).pipe(Alchemy.remote());
281 const site = yield* Cloudflare.Website.Nextjs(
282 "NextjsRemoteKvLocal",
283 {
284 rootDir,
285 dev: { port: 0 },
286 memo: { include: memoInclude },
287 env: {
288 TEST_TEXT: "nextjs-dev-remote-marker",
289 FIXTURE_KV: siteKv,
290 },
291 },
292 );
293 return { site, siteKv };
294 }),
295 );
296 const site = deployed.site;
297
298 // Served locally, but the remote() namespace is REAL — a live cloud
299 // id, not a `dev:` fabrication.
300 expect(site.url).toMatch(/^http:\/\/localhost:\d+/);
301 expect(deployed.siteKv.namespaceId).toBeDefined();
302 expect(deployed.siteKv.namespaceId).not.toMatch(/^dev:/);
303
304 // Write through the worker's remote-proxied binding.
305 yield* putKv(site.url!, "remote-key", "remote-value");
306 const got = yield* fetchJsonReady<{ value: string | null }>(
307 `${site.url!}/api/kv?key=remote-key`,
308 );
309 expect(got.value).toBe("remote-value");
310
311 // Out-of-band: the write is visible through the cloud KV API — the
312 // remote-proxied binding really hit the live namespace.
313 const { accountId } = yield* yield* CloudflareEnvironment;
314 const observed = yield* kv
315 .getNamespaceValue({
316 accountId,
317 namespaceId: deployed.siteKv.namespaceId,
318 keyName: "remote-key",
319 })
320 .pipe(
321 Effect.retry({
322 while: (e): boolean => e._tag === "KeyNotFound",
323 schedule: Schedule.min([
324 Schedule.exponential("1 second"),
325 Schedule.spaced("3 seconds"),
326 ]),
327 times: 8,
328 }),
329 Effect.flatMap((res) =>
330 Effect.tryPromise(() =>
331 new Response(
332 Stream.toReadableStream(res.body) as BodyInit,
333 ).text(),
334 ),
335 ),
336 );
337 expect(observed).toBe("remote-value");
338
339 yield* stack.destroy();
340
341 // The live namespace was deleted from the cloud on destroy (its
342 // state row is stamped live, so the live provider handles the
343 // delete even in a dev run).
344 const gone = yield* kv
345 .getNamespace({
346 accountId,
347 namespaceId: deployed.siteKv.namespaceId,
348 })
349 .pipe(
350 Effect.as(false),
351 Effect.catchTag("NamespaceNotFound", () => Effect.succeed(true)),
352 );
353 expect(gone).toBe(true);
354 }).pipe(logLevel),
355 { tags: ["provider:cloudflare:kv", "live"], timeout: 420_000 },
356 );
357 },
358);