t3-code-android-nightly/.repos/alchemy-effect/packages/alchemy/test/Cloudflare/Website/Nextjs.test.ts
Julius Marminge 6f9cea00ae
chore(refs): sync Effect and Alchemy references to 4.0.1 and beta.80 (#16170)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:22:30 -07:00

450 lines
17 KiB
TypeScript

import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
import * as Cloudflare from "@/Cloudflare/index.ts";
import * as Test from "@/Test/Alchemy";
import { describe, expect } from "alchemy-test";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import { MinimumLogLevel } from "effect/References";
import * as Schedule from "effect/Schedule";
import * as HttpClient from "effect/http/HttpClient";
import * as HttpClientRequest from "effect/http/HttpClientRequest";
import * as pathe from "pathe";
import { cloneFixture } from "../Utils/Fixture.ts";
import { expectUrlContains } from "../Utils/Http.ts";
import { prepareNextjsFixture } from "./TypeScriptCompat.ts";
import { buildCommand as nativeBuildCommand } from "./fixtures/nextjs-app/open-next.native.config.ts";
import {
expectWorkerExists,
waitForWorkerToBeDeleted,
} from "../Utils/Worker.ts";
const { test } = Test.make({ providers: Cloudflare.providers() });
const logLevel = Effect.provideService(
MinimumLogLevel,
process.env.DEBUG ? "Debug" : "Info",
);
const fixtureDir = pathe.resolve(import.meta.dirname, "fixtures", "nextjs-app");
const nextjsProps = (rootDir: string) => ({
rootDir,
workersDev: { enabled: true, previewsEnabled: true },
memo: {
include: [
"app/**",
"pages/**",
"public/**",
"package.json",
"tsconfig.json",
"middleware.ts",
"next.config.mjs",
],
},
});
const fetchJsonReady = <T>(url: string) =>
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
return yield* client.get(url).pipe(
Effect.flatMap((res) =>
Effect.flatMap(res.text, (body) =>
res.status === 200
? Effect.try({
try: () => JSON.parse(body) as T,
catch: () => new Error(`non-json body: ${body}`),
})
: Effect.fail(
new Error(
`Worker not ready (${res.status}): ${body.slice(0, 300)}`,
),
),
),
),
// Bounded: ~60s of edge propagation, then fail with the last body.
Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 30 }),
);
});
// Tests are independent (per-test scratch stacks, private fixture clones),
// so run them concurrently; suites are sequential by default.
describe.concurrent(
"Nextjs",
{
tags: [
"provider:cloudflare",
"provider:cloudflare:kv",
"provider:cloudflare:website",
"provider:cloudflare:worker",
"live",
],
},
() => {
test.provider(
"Nextjs: deploys SSR + binding + static assets, serves prerendered ISR, and memoizes unchanged rebuilds",
(stack) =>
Effect.gen(function* () {
const { accountId } = yield* yield* CloudflareEnvironment;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
yield* stack.destroy();
// Clone outside the repo so Next does not treat the alchemy
// monorepo as the workspace root (that lookup hits typescript 7
// / tsgo, which has no `lib/typescript.js`).
const rootDir = yield* cloneFixture(fixtureDir, {
prefix: "alchemy-nextjs-",
entries: [
"package.json",
"tsconfig.json",
"next.config.mjs",
"middleware.ts",
"app",
"pages",
"public",
],
});
yield* prepareNextjsFixture(rootDir);
const nextConfigPath = path.join(rootDir, "next.config.mjs");
const nextConfig = yield* fs.readFileString(nextConfigPath);
expect(
yield* fs.exists(path.join(rootDir, "open-next.config.ts")),
).toBe(false);
const bindingMarker = "nextjs-binding-marker";
const deploy = (buildCommand?: string) =>
stack.deploy(
Effect.gen(function* () {
const kv = yield* Cloudflare.KV.Namespace("NextjsFixtureKv");
return yield* Cloudflare.Website.Nextjs("NextjsSite", {
...nextjsProps(rootDir),
openNext:
buildCommand === undefined ? undefined : { buildCommand },
env: {
TEST_TEXT: bindingMarker,
FIXTURE_KV: kv,
},
});
}),
);
const site1 = yield* deploy();
expect(yield* fs.readFileString(nextConfigPath)).toBe(nextConfig);
expect(
yield* fs.exists(path.join(rootDir, "open-next.config.ts")),
).toBe(false);
expect(site1.url).toBeDefined();
expect(site1.hash?.input).toBeDefined();
yield* expectWorkerExists(site1.workerName, accountId);
// Dynamic (force-dynamic) app-router page rendered by the Worker.
yield* expectUrlContains(`${site1.url!}/`, "NEXTJS_SSR_MARKER", {
timeout: "120 seconds",
label: "nextjs SSR home page",
});
// API route handler — the middleware matcher covers /api/*, so the
// pass-through header also proves middleware executes on deploy.
const client = yield* HttpClient.HttpClient;
const helloRes = yield* Test.getWhenReady(`${site1.url!}/api/hello`, {
times: 6,
});
expect(helloRes.status).toBe(200);
expect(helloRes.headers["x-fixture-middleware"]).toBe("passed");
const hello = (yield* helloRes.json) as { hello: string };
expect(hello.hello).toBe("world");
// Middleware rewrite: /mw-rewrite serves the API route's response.
const rewritten = yield* fetchJsonReady<{ hello: string }>(
`${site1.url!}/mw-rewrite`,
);
expect(rewritten.hello).toBe("world");
// Binding read through OpenNext's `getCloudflareContext()`.
const binding = yield* fetchJsonReady<{ value: string | null }>(
`${site1.url!}/api/binding`,
);
expect(binding.value).toBe(bindingMarker);
// KV round-trip through a real resource binding: PUT then GET.
const kvKey = `nextjs-live-${site1.hash?.input?.slice(0, 8)}`;
const kvValue = `kv-value-${bindingMarker}`;
const putRes = yield* Test.executeWhenReady(
HttpClientRequest.put(`${site1.url!}/api/kv`).pipe(
HttpClientRequest.bodyJsonUnsafe({ key: kvKey, value: kvValue }),
),
{ times: 6 },
);
expect(putRes.status).toBe(200);
const kvRead = yield* fetchJsonReady<{ value: string | null }>(
`${site1.url!}/api/kv?key=${kvKey}`,
);
expect(kvRead.value).toBe(kvValue);
// Static asset from `public/`.
yield* expectUrlContains(
`${site1.url!}/static.txt`,
"NEXTJS_STATIC_ASSET_MARKER",
{
timeout: "60 seconds",
label: "nextjs static asset",
},
);
// ISR page: the prerendered payload serves (read-only static-assets
// incremental cache; revalidation writes are a documented no-op).
yield* expectUrlContains(`${site1.url!}/isr`, "NEXTJS_ISR_MARKER", {
timeout: "60 seconds",
label: "nextjs prerendered ISR page",
});
// Dynamic segment prerendered by generateStaticParams (SSG).
yield* expectUrlContains(
`${site1.url!}/products/alpha`,
"product-slug:",
{
timeout: "60 seconds",
label: "nextjs prerendered dynamic segment",
},
);
// Non-prerendered slug renders on demand in the Worker.
yield* expectUrlContains(
`${site1.url!}/products/gamma`,
"product-slug:",
{
timeout: "60 seconds",
label: "nextjs on-demand dynamic segment",
},
);
// Catch-all dynamic segment.
yield* expectUrlContains(
`${site1.url!}/docs/guides/deploy/workers`,
"DOCS_CATCHALL_MARKER",
{ timeout: "60 seconds", label: "nextjs catch-all segment" },
);
// Streaming SSR through the real edge: the shell (with the Suspense
// fallback) flushes before the slow segment resolves. identity
// encoding keeps intermediate proxies from buffering the stream.
const streamRes = yield* Test.executeWhenReady(
HttpClientRequest.get(`${site1.url!}/streaming`).pipe(
HttpClientRequest.setHeader("accept-encoding", "identity"),
),
{ times: 6 },
);
expect(streamRes.status).toBe(200);
const streamBody = yield* streamRes.text;
expect(streamBody).toContain("STREAMING_SUSPENSE_MARKER");
expect(streamBody).toContain("STREAMING_RESOLVED_MARKER");
// Custom not-found boundary: unmatched routes and notFound() calls
// both serve the custom 404 content with a 404 status. A colo that
// hasn't picked up the deployment yet serves Cloudflare's platform
// "Page not found" (also a 404), so retry until the response is the
// app's own boundary rather than asserting the first answer.
const expectCustom404 = (url: string, label: string) =>
client.get(url).pipe(
Effect.flatMap((res) =>
Effect.flatMap(res.text, (body) =>
res.status === 404 && body.includes("CUSTOM_NOT_FOUND_MARKER")
? Effect.void
: Effect.fail(
new Error(
`${label}: status=${res.status}, custom not-found marker absent`,
),
),
),
),
Effect.retry({
schedule: Schedule.spaced("2 seconds"),
times: 30,
}),
);
yield* expectCustom404(
`${site1.url!}/definitely/not/a/route`,
"unmatched route",
);
yield* expectCustom404(`${site1.url!}/gone`, "notFound() page");
// next.config redirects / rewrites / headers. The fetch-backed
// client follows the 308, so assert the redirect lands on the home
// page's content — a broken redirect would 404 instead.
yield* expectUrlContains(
`${site1.url!}/old-home`,
"NEXTJS_SSR_MARKER",
{
timeout: "60 seconds",
label: "nextjs next.config redirect (followed to home)",
},
);
const rewritten2 = yield* fetchJsonReady<{ hello: string }>(
`${site1.url!}/rewritten-hello`,
);
expect(rewritten2.hello).toBe("world");
const headered = yield* Test.getWhenReady(`${site1.url!}/api/hello`, {
times: 6,
});
expect(headered.headers["x-fixture-config-header"]).toBe(
"from-next-config",
);
// next/image (unoptimized): the page renders the img and the raw
// asset serves through the ASSETS binding. Real optimization needs
// a zone with Cloudflare Images — documented on the resource.
yield* expectUrlContains(`${site1.url!}/image`, "IMAGE_PAGE_MARKER", {
timeout: "60 seconds",
label: "nextjs next/image page",
});
const pixel = yield* Test.getWhenReady(`${site1.url!}/pixel.png`, {
times: 6,
});
expect(pixel.status).toBe(200);
expect(pixel.headers["content-type"]).toContain("image/png");
// Pages Router page (getServerSideProps) + API route, coexisting
// with the App Router.
yield* expectUrlContains(
`${site1.url!}/legacy`,
"PAGES_ROUTER_MARKER",
{
timeout: "60 seconds",
label: "nextjs pages-router page",
},
);
const legacy = yield* fetchJsonReady<{ legacy: string }>(
`${site1.url!}/api/legacy`,
);
expect(legacy.legacy).toBe("pages-api");
// ── deploy 2: no changes ⇒ the rebuild-free input hash matches and
// the deploy short-circuits without rebuilding ───────────────────────
const site2 = yield* deploy();
expect(site2.hash?.input).toBeDefined();
expect(site2.hash?.input).toEqual(site1.hash?.input);
expect(site2.url).toBe(site1.url);
// ── deploy 3: edit the SSR page ⇒ the input hash changes and the
// new content deploys ────────────────────────────────────────────────
const pagePath = path.join(rootDir, "app/page.tsx");
const page = yield* fs.readFileString(pagePath);
yield* fs.writeFileString(
pagePath,
page.replace("NEXTJS_SSR_MARKER", "NEXTJS_SSR_MARKER_V2"),
);
const site3 = yield* deploy();
expect(site3.hash?.input).toBeDefined();
expect(site3.hash?.input).not.toEqual(site1.hash?.input);
yield* expectUrlContains(`${site3.url!}/`, "NEXTJS_SSR_MARKER_V2", {
timeout: "120 seconds",
label: "nextjs SSR page after edit",
});
const configRoute = () =>
client.get(`${site1.url!}/api/config`).pipe(
Effect.flatMap((response) =>
response.text.pipe(
Effect.as(response.headers["x-config-priority"]),
),
),
Effect.repeat({
schedule: Schedule.spaced("1 second"),
times: 30,
until: (priority) => priority === "handler",
}),
);
const nativeConfigPath = path.join(rootDir, "open-next.config.ts");
const nativeConfig = yield* fs.readFileString(
path.join(fixtureDir, "open-next.native.config.ts"),
);
const nativeApp = path.join(rootDir, "native-output");
yield* fs.makeDirectory(nativeApp, { recursive: true });
for (const entry of [
"package.json",
"tsconfig.json",
"next.config.mjs",
"middleware.ts",
"app",
"pages",
"public",
]) {
yield* fs.copy(
path.join(rootDir, entry),
path.join(nativeApp, entry),
);
}
yield* fs.writeFileString(nativeConfigPath, nativeConfig);
const site4 = yield* deploy();
expect(site4.hash?.input).not.toEqual(site3.hash?.input);
expect(yield* fs.readFileString(nativeConfigPath)).toBe(nativeConfig);
expect(
yield* fs.readFileString(path.join(rootDir, "native-build-marker")),
).toBe("native");
expect(
yield* fs.exists(
path.join(rootDir, "native-output", ".open-next", "worker.js"),
),
).toBe(true);
expect(yield* configRoute()).toBe("handler");
yield* expectUrlContains(`${site4.url!}/`, "NEXTJS_SSR_MARKER_V2");
yield* expectUrlContains(
`${site4.url!}/static.txt`,
"NEXTJS_STATIC_ASSET_MARKER",
);
expect((yield* deploy()).hash?.input).toEqual(site4.hash?.input);
const editedConfig = nativeConfig.replace(
'["/api/config"]',
'["/other"]',
);
yield* fs.writeFileString(nativeConfigPath, editedConfig);
const site5 = yield* deploy();
expect(site5.hash?.input).not.toEqual(site4.hash?.input);
expect(yield* fs.readFileString(nativeConfigPath)).toBe(editedConfig);
expect(
yield* fs.readFileString(path.join(rootDir, "native-build-marker")),
).toBe("native");
const middlewarePriority = yield* client
.get(`${site5.url!}/api/config`)
.pipe(
Effect.flatMap((response) =>
response.text.pipe(
Effect.as(response.headers["x-config-priority"]),
),
),
Effect.repeat({
schedule: Schedule.spaced("1 second"),
times: 30,
until: (priority) => priority === "middleware",
}),
);
expect(middlewarePriority).toBe("middleware");
const overrideSite = yield* deploy(
nativeBuildCommand.replace("'native'", "'resource'"),
);
expect(overrideSite.hash?.input).not.toEqual(site5.hash?.input);
expect(
yield* fs.readFileString(path.join(rootDir, "native-build-marker")),
).toBe("resource");
expect(yield* fs.readFileString(nativeConfigPath)).toBe(editedConfig);
yield* fs.remove(nativeConfigPath);
const site6 = yield* deploy();
expect(site6.hash?.input).toEqual(site3.hash?.input);
expect(yield* fs.exists(nativeConfigPath)).toBe(false);
yield* expectUrlContains(`${site6.url!}/`, "NEXTJS_SSR_MARKER_V2");
yield* stack.destroy();
yield* waitForWorkerToBeDeleted(site1.workerName, accountId);
}).pipe(logLevel),
{ timeout: 600_000 },
);
},
);