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 { expectUrlContains } from "../Utils/Http.ts";
15import { prepareNextjsFixture } from "./TypeScriptCompat.ts";
16import { buildCommand as nativeBuildCommand } from "./fixtures/nextjs-app/open-next.native.config.ts";
17import {
18 expectWorkerExists,
19 waitForWorkerToBeDeleted,
20} from "../Utils/Worker.ts";
21
22const { test } = Test.make({ providers: Cloudflare.providers() });
23
24const logLevel = Effect.provideService(
25 MinimumLogLevel,
26 process.env.DEBUG ? "Debug" : "Info",
27);
28
29const fixtureDir = pathe.resolve(import.meta.dirname, "fixtures", "nextjs-app");
30
31const nextjsProps = (rootDir: string) => ({
32 rootDir,
33 workersDev: { enabled: true, previewsEnabled: true },
34 memo: {
35 include: [
36 "app/**",
37 "pages/**",
38 "public/**",
39 "package.json",
40 "tsconfig.json",
41 "middleware.ts",
42 "next.config.mjs",
43 ],
44 },
45});
46
47const fetchJsonReady = <T>(url: string) =>
48 Effect.gen(function* () {
49 const client = yield* HttpClient.HttpClient;
50 return yield* client.get(url).pipe(
51 Effect.flatMap((res) =>
52 Effect.flatMap(res.text, (body) =>
53 res.status === 200
54 ? Effect.try({
55 try: () => JSON.parse(body) as T,
56 catch: () => new Error(`non-json body: ${body}`),
57 })
58 : Effect.fail(
59 new Error(
60 `Worker not ready (${res.status}): ${body.slice(0, 300)}`,
61 ),
62 ),
63 ),
64 ),
65 // Bounded: ~60s of edge propagation, then fail with the last body.
66 Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 30 }),
67 );
68 });
69
70// Tests are independent (per-test scratch stacks, private fixture clones),
71// so run them concurrently; suites are sequential by default.
72describe.concurrent(
73 "Nextjs",
74 {
75 tags: [
76 "provider:cloudflare",
77 "provider:cloudflare:kv",
78 "provider:cloudflare:website",
79 "provider:cloudflare:worker",
80 "live",
81 ],
82 },
83 () => {
84 test.provider(
85 "Nextjs: deploys SSR + binding + static assets, serves prerendered ISR, and memoizes unchanged rebuilds",
86 (stack) =>
87 Effect.gen(function* () {
88 const { accountId } = yield* yield* CloudflareEnvironment;
89 const fs = yield* FileSystem.FileSystem;
90 const path = yield* Path.Path;
91
92 yield* stack.destroy();
93
94 // Clone outside the repo so Next does not treat the alchemy
95 // monorepo as the workspace root (that lookup hits typescript 7
96 // / tsgo, which has no `lib/typescript.js`).
97 const rootDir = yield* cloneFixture(fixtureDir, {
98 prefix: "alchemy-nextjs-",
99 entries: [
100 "package.json",
101 "tsconfig.json",
102 "next.config.mjs",
103 "middleware.ts",
104 "app",
105 "pages",
106 "public",
107 ],
108 });
109 yield* prepareNextjsFixture(rootDir);
110 const nextConfigPath = path.join(rootDir, "next.config.mjs");
111 const nextConfig = yield* fs.readFileString(nextConfigPath);
112 expect(
113 yield* fs.exists(path.join(rootDir, "open-next.config.ts")),
114 ).toBe(false);
115
116 const bindingMarker = "nextjs-binding-marker";
117
118 const deploy = (buildCommand?: string) =>
119 stack.deploy(
120 Effect.gen(function* () {
121 const kv = yield* Cloudflare.KV.Namespace("NextjsFixtureKv");
122 return yield* Cloudflare.Website.Nextjs("NextjsSite", {
123 ...nextjsProps(rootDir),
124 openNext:
125 buildCommand === undefined ? undefined : { buildCommand },
126 env: {
127 TEST_TEXT: bindingMarker,
128 FIXTURE_KV: kv,
129 },
130 });
131 }),
132 );
133
134 const site1 = yield* deploy();
135 expect(yield* fs.readFileString(nextConfigPath)).toBe(nextConfig);
136 expect(
137 yield* fs.exists(path.join(rootDir, "open-next.config.ts")),
138 ).toBe(false);
139
140 expect(site1.url).toBeDefined();
141 expect(site1.hash?.input).toBeDefined();
142 yield* expectWorkerExists(site1.workerName, accountId);
143
144 // Dynamic (force-dynamic) app-router page rendered by the Worker.
145 yield* expectUrlContains(`${site1.url!}/`, "NEXTJS_SSR_MARKER", {
146 timeout: "120 seconds",
147 label: "nextjs SSR home page",
148 });
149
150 // API route handler — the middleware matcher covers /api/*, so the
151 // pass-through header also proves middleware executes on deploy.
152 const client = yield* HttpClient.HttpClient;
153 const helloRes = yield* Test.getWhenReady(`${site1.url!}/api/hello`, {
154 times: 6,
155 });
156 expect(helloRes.status).toBe(200);
157 expect(helloRes.headers["x-fixture-middleware"]).toBe("passed");
158 const hello = (yield* helloRes.json) as { hello: string };
159 expect(hello.hello).toBe("world");
160
161 // Middleware rewrite: /mw-rewrite serves the API route's response.
162 const rewritten = yield* fetchJsonReady<{ hello: string }>(
163 `${site1.url!}/mw-rewrite`,
164 );
165 expect(rewritten.hello).toBe("world");
166
167 // Binding read through OpenNext's `getCloudflareContext()`.
168 const binding = yield* fetchJsonReady<{ value: string | null }>(
169 `${site1.url!}/api/binding`,
170 );
171 expect(binding.value).toBe(bindingMarker);
172
173 // KV round-trip through a real resource binding: PUT then GET.
174 const kvKey = `nextjs-live-${site1.hash?.input?.slice(0, 8)}`;
175 const kvValue = `kv-value-${bindingMarker}`;
176 const putRes = yield* Test.executeWhenReady(
177 HttpClientRequest.put(`${site1.url!}/api/kv`).pipe(
178 HttpClientRequest.bodyJsonUnsafe({ key: kvKey, value: kvValue }),
179 ),
180 { times: 6 },
181 );
182 expect(putRes.status).toBe(200);
183 const kvRead = yield* fetchJsonReady<{ value: string | null }>(
184 `${site1.url!}/api/kv?key=${kvKey}`,
185 );
186 expect(kvRead.value).toBe(kvValue);
187
188 // Static asset from `public/`.
189 yield* expectUrlContains(
190 `${site1.url!}/static.txt`,
191 "NEXTJS_STATIC_ASSET_MARKER",
192 {
193 timeout: "60 seconds",
194 label: "nextjs static asset",
195 },
196 );
197
198 // ISR page: the prerendered payload serves (read-only static-assets
199 // incremental cache; revalidation writes are a documented no-op).
200 yield* expectUrlContains(`${site1.url!}/isr`, "NEXTJS_ISR_MARKER", {
201 timeout: "60 seconds",
202 label: "nextjs prerendered ISR page",
203 });
204
205 // Dynamic segment prerendered by generateStaticParams (SSG).
206 yield* expectUrlContains(
207 `${site1.url!}/products/alpha`,
208 "product-slug:",
209 {
210 timeout: "60 seconds",
211 label: "nextjs prerendered dynamic segment",
212 },
213 );
214 // Non-prerendered slug renders on demand in the Worker.
215 yield* expectUrlContains(
216 `${site1.url!}/products/gamma`,
217 "product-slug:",
218 {
219 timeout: "60 seconds",
220 label: "nextjs on-demand dynamic segment",
221 },
222 );
223 // Catch-all dynamic segment.
224 yield* expectUrlContains(
225 `${site1.url!}/docs/guides/deploy/workers`,
226 "DOCS_CATCHALL_MARKER",
227 { timeout: "60 seconds", label: "nextjs catch-all segment" },
228 );
229
230 // Streaming SSR through the real edge: the shell (with the Suspense
231 // fallback) flushes before the slow segment resolves. identity
232 // encoding keeps intermediate proxies from buffering the stream.
233 const streamRes = yield* Test.executeWhenReady(
234 HttpClientRequest.get(`${site1.url!}/streaming`).pipe(
235 HttpClientRequest.setHeader("accept-encoding", "identity"),
236 ),
237 { times: 6 },
238 );
239 expect(streamRes.status).toBe(200);
240 const streamBody = yield* streamRes.text;
241 expect(streamBody).toContain("STREAMING_SUSPENSE_MARKER");
242 expect(streamBody).toContain("STREAMING_RESOLVED_MARKER");
243
244 // Custom not-found boundary: unmatched routes and notFound() calls
245 // both serve the custom 404 content with a 404 status. A colo that
246 // hasn't picked up the deployment yet serves Cloudflare's platform
247 // "Page not found" (also a 404), so retry until the response is the
248 // app's own boundary rather than asserting the first answer.
249 const expectCustom404 = (url: string, label: string) =>
250 client.get(url).pipe(
251 Effect.flatMap((res) =>
252 Effect.flatMap(res.text, (body) =>
253 res.status === 404 && body.includes("CUSTOM_NOT_FOUND_MARKER")
254 ? Effect.void
255 : Effect.fail(
256 new Error(
257 `${label}: status=${res.status}, custom not-found marker absent`,
258 ),
259 ),
260 ),
261 ),
262 Effect.retry({
263 schedule: Schedule.spaced("2 seconds"),
264 times: 30,
265 }),
266 );
267 yield* expectCustom404(
268 `${site1.url!}/definitely/not/a/route`,
269 "unmatched route",
270 );
271 yield* expectCustom404(`${site1.url!}/gone`, "notFound() page");
272
273 // next.config redirects / rewrites / headers. The fetch-backed
274 // client follows the 308, so assert the redirect lands on the home
275 // page's content — a broken redirect would 404 instead.
276 yield* expectUrlContains(
277 `${site1.url!}/old-home`,
278 "NEXTJS_SSR_MARKER",
279 {
280 timeout: "60 seconds",
281 label: "nextjs next.config redirect (followed to home)",
282 },
283 );
284 const rewritten2 = yield* fetchJsonReady<{ hello: string }>(
285 `${site1.url!}/rewritten-hello`,
286 );
287 expect(rewritten2.hello).toBe("world");
288 const headered = yield* Test.getWhenReady(`${site1.url!}/api/hello`, {
289 times: 6,
290 });
291 expect(headered.headers["x-fixture-config-header"]).toBe(
292 "from-next-config",
293 );
294
295 // next/image (unoptimized): the page renders the img and the raw
296 // asset serves through the ASSETS binding. Real optimization needs
297 // a zone with Cloudflare Images — documented on the resource.
298 yield* expectUrlContains(`${site1.url!}/image`, "IMAGE_PAGE_MARKER", {
299 timeout: "60 seconds",
300 label: "nextjs next/image page",
301 });
302 const pixel = yield* Test.getWhenReady(`${site1.url!}/pixel.png`, {
303 times: 6,
304 });
305 expect(pixel.status).toBe(200);
306 expect(pixel.headers["content-type"]).toContain("image/png");
307
308 // Pages Router page (getServerSideProps) + API route, coexisting
309 // with the App Router.
310 yield* expectUrlContains(
311 `${site1.url!}/legacy`,
312 "PAGES_ROUTER_MARKER",
313 {
314 timeout: "60 seconds",
315 label: "nextjs pages-router page",
316 },
317 );
318 const legacy = yield* fetchJsonReady<{ legacy: string }>(
319 `${site1.url!}/api/legacy`,
320 );
321 expect(legacy.legacy).toBe("pages-api");
322
323 // ── deploy 2: no changes ⇒ the rebuild-free input hash matches and
324 // the deploy short-circuits without rebuilding ───────────────────────
325 const site2 = yield* deploy();
326
327 expect(site2.hash?.input).toBeDefined();
328 expect(site2.hash?.input).toEqual(site1.hash?.input);
329 expect(site2.url).toBe(site1.url);
330
331 // ── deploy 3: edit the SSR page ⇒ the input hash changes and the
332 // new content deploys ────────────────────────────────────────────────
333 const pagePath = path.join(rootDir, "app/page.tsx");
334 const page = yield* fs.readFileString(pagePath);
335 yield* fs.writeFileString(
336 pagePath,
337 page.replace("NEXTJS_SSR_MARKER", "NEXTJS_SSR_MARKER_V2"),
338 );
339
340 const site3 = yield* deploy();
341
342 expect(site3.hash?.input).toBeDefined();
343 expect(site3.hash?.input).not.toEqual(site1.hash?.input);
344 yield* expectUrlContains(`${site3.url!}/`, "NEXTJS_SSR_MARKER_V2", {
345 timeout: "120 seconds",
346 label: "nextjs SSR page after edit",
347 });
348
349 const configRoute = () =>
350 client.get(`${site1.url!}/api/config`).pipe(
351 Effect.flatMap((response) =>
352 response.text.pipe(
353 Effect.as(response.headers["x-config-priority"]),
354 ),
355 ),
356 Effect.repeat({
357 schedule: Schedule.spaced("1 second"),
358 times: 30,
359 until: (priority) => priority === "handler",
360 }),
361 );
362 const nativeConfigPath = path.join(rootDir, "open-next.config.ts");
363 const nativeConfig = yield* fs.readFileString(
364 path.join(fixtureDir, "open-next.native.config.ts"),
365 );
366 const nativeApp = path.join(rootDir, "native-output");
367 yield* fs.makeDirectory(nativeApp, { recursive: true });
368 for (const entry of [
369 "package.json",
370 "tsconfig.json",
371 "next.config.mjs",
372 "middleware.ts",
373 "app",
374 "pages",
375 "public",
376 ]) {
377 yield* fs.copy(
378 path.join(rootDir, entry),
379 path.join(nativeApp, entry),
380 );
381 }
382 yield* fs.writeFileString(nativeConfigPath, nativeConfig);
383 const site4 = yield* deploy();
384 expect(site4.hash?.input).not.toEqual(site3.hash?.input);
385 expect(yield* fs.readFileString(nativeConfigPath)).toBe(nativeConfig);
386 expect(
387 yield* fs.readFileString(path.join(rootDir, "native-build-marker")),
388 ).toBe("native");
389 expect(
390 yield* fs.exists(
391 path.join(rootDir, "native-output", ".open-next", "worker.js"),
392 ),
393 ).toBe(true);
394 expect(yield* configRoute()).toBe("handler");
395 yield* expectUrlContains(`${site4.url!}/`, "NEXTJS_SSR_MARKER_V2");
396 yield* expectUrlContains(
397 `${site4.url!}/static.txt`,
398 "NEXTJS_STATIC_ASSET_MARKER",
399 );
400 expect((yield* deploy()).hash?.input).toEqual(site4.hash?.input);
401
402 const editedConfig = nativeConfig.replace(
403 '["/api/config"]',
404 '["/other"]',
405 );
406 yield* fs.writeFileString(nativeConfigPath, editedConfig);
407 const site5 = yield* deploy();
408 expect(site5.hash?.input).not.toEqual(site4.hash?.input);
409 expect(yield* fs.readFileString(nativeConfigPath)).toBe(editedConfig);
410 expect(
411 yield* fs.readFileString(path.join(rootDir, "native-build-marker")),
412 ).toBe("native");
413 const middlewarePriority = yield* client
414 .get(`${site5.url!}/api/config`)
415 .pipe(
416 Effect.flatMap((response) =>
417 response.text.pipe(
418 Effect.as(response.headers["x-config-priority"]),
419 ),
420 ),
421 Effect.repeat({
422 schedule: Schedule.spaced("1 second"),
423 times: 30,
424 until: (priority) => priority === "middleware",
425 }),
426 );
427 expect(middlewarePriority).toBe("middleware");
428
429 const overrideSite = yield* deploy(
430 nativeBuildCommand.replace("'native'", "'resource'"),
431 );
432 expect(overrideSite.hash?.input).not.toEqual(site5.hash?.input);
433 expect(
434 yield* fs.readFileString(path.join(rootDir, "native-build-marker")),
435 ).toBe("resource");
436 expect(yield* fs.readFileString(nativeConfigPath)).toBe(editedConfig);
437
438 yield* fs.remove(nativeConfigPath);
439 const site6 = yield* deploy();
440 expect(site6.hash?.input).toEqual(site3.hash?.input);
441 expect(yield* fs.exists(nativeConfigPath)).toBe(false);
442 yield* expectUrlContains(`${site6.url!}/`, "NEXTJS_SSR_MARKER_V2");
443
444 yield* stack.destroy();
445 yield* waitForWorkerToBeDeleted(site1.workerName, accountId);
446 }).pipe(logLevel),
447 { timeout: 600_000 },
448 );
449 },
450);