1import * as AWS from "@/AWS";
2import * as Test from "@/Test/Alchemy";
3import * as cloudfront from "@distilled.cloud/aws/cloudfront";
4import { describe, expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import { spawn } from "node:child_process";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as pathe from "pathe";
10import { cloneFixture } from "../../Cloudflare/Utils/Fixture.ts";
11import { expectUrlContains } from "../../Cloudflare/Utils/Http.ts";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15// Gated with the rest of the AWS.Website suites: the OpenNext build plus the
16// CloudFront lifecycle dominate the runtime (create ~10-20 min, destroy
17// ~5-15 min).
18const runLive = !process.env.FAST;
19
20const fixtureDir = pathe.resolve(import.meta.dirname, "fixtures", "nextjs-app");
21
22const fixtureEntries = [
23 ".gitignore",
24 "package.json",
25 "next.config.ts",
26 "tsconfig.json",
27 "app",
28 "public",
29];
30
31/**
32 * Run a command to completion in a child process (Effect-wrapped spawn —
33 * the suite shares one bun process, so a sync spawn would stall every
34 * concurrently running test). Fails with the combined output on a
35 * non-zero exit.
36 */
37const run = (options: {
38 cmd: string;
39 args: string[];
40 cwd: string;
41}): Effect.Effect<string, Error> =>
42 Effect.callback<string, Error>((resume) => {
43 const child = spawn(options.cmd, options.args, {
44 cwd: options.cwd,
45 env: { ...process.env, NO_COLOR: "1" },
46 stdio: ["ignore", "pipe", "pipe"],
47 });
48 let output = "";
49 child.stdout.on("data", (chunk) => (output += chunk));
50 child.stderr.on("data", (chunk) => (output += chunk));
51 child.once("error", (error) => resume(Effect.fail(error)));
52 child.once("close", (code) =>
53 resume(
54 code === 0
55 ? Effect.succeed(output)
56 : Effect.fail(
57 new Error(
58 `${options.cmd} ${options.args.join(" ")} exited ${code}:\n${output}`,
59 ),
60 ),
61 ),
62 );
63 return Effect.sync(() => child.kill("SIGKILL"));
64 });
65
66// Skipped under the floci runner: in dev the composite deploys only the
67// framework dev server (no Lambda/S3/CloudFront), so this test's live
68// topology assertions are meaningless there. Dev behavior is covered by
69// the co-located Nextjs.local.test.ts suite.
70const runEmulated = process.env.ALCHEMY_TEST_DEV === "1";
71
72describe.skipIf(!runLive || runEmulated)(
73 "AWS.Website.Nextjs",
74 {
75 tags: [
76 "provider:aws",
77 "provider:aws:cloudfront",
78 "provider:aws:website",
79 "live",
80 ],
81 },
82 () => {
83 test.provider(
84 "deploys the OpenNext topology: streaming SSR Lambda, S3 assets, image optimizer, ISR wiring",
85 (stack) =>
86 Effect.gen(function* () {
87 yield* stack.destroy();
88
89 // Clone OUTSIDE the repo (OS temp dir): an in-workspace clone makes
90 // OpenNext detect the workspace as a monorepo and trace the server
91 // bundle against bun's isolated-install symlink store, which drops
92 // transitive deps (e.g. @swc/helpers) from the shipped node_modules.
93 const rootDir = yield* cloneFixture(fixtureDir, {
94 prefix: "alchemy-nextjs-aws-",
95 entries: fixtureEntries,
96 });
97 // Hoisted install in the clone so output tracing sees a plain
98 // node_modules tree — the representative user-project shape.
99 yield* run({
100 cmd: "bun",
101 args: ["install", "--linker=hoisted"],
102 cwd: rootDir,
103 });
104
105 const deployed = yield* stack.deploy(
106 Effect.gen(function* () {
107 const site = yield* AWS.Website.Nextjs("NextjsSite", {
108 rootDir,
109 forceDestroy: true,
110 invalidation: { paths: "all", wait: true },
111 });
112 return { site };
113 }),
114 );
115
116 const url = deployed.site.url! as string;
117 expect(url).toMatch(/^https:\/\//);
118 expect(deployed.site.serverUrl).toBeDefined();
119 expect(deployed.site.imageUrl).toBeDefined();
120 expect(deployed.site.revalidationQueue).toBeDefined();
121 expect(deployed.site.tagCacheTable).toBeDefined();
122 yield* Effect.log(
123 `site url: ${url} | server url: ${deployed.site.serverUrl}`,
124 );
125
126 // The Lambda Function URL serves the SSR page directly — isolates
127 // server-function health from the CloudFront edge routing.
128 yield* expectUrlContains(
129 `${deployed.site.serverUrl!}`,
130 "NEXTJS_AWS_PAGE_MARKER",
131 {
132 timeout: "120 seconds",
133 label: "SSR direct from Lambda URL",
134 },
135 );
136
137 // SSR page rendered by the Lambda through CloudFront.
138 yield* expectUrlContains(`${url}/`, "NEXTJS_AWS_PAGE_MARKER", {
139 timeout: "180 seconds",
140 label: "SSR home page",
141 });
142 // App Router API route through the streaming Function URL origin.
143 yield* expectUrlContains(
144 `${url}/api/hello?echo=roundtrip`,
145 "NEXTJS_AWS_API_MARKER",
146 { label: "API route" },
147 );
148 yield* expectUrlContains(
149 `${url}/api/hello?echo=roundtrip`,
150 "roundtrip",
151 { label: "API route query echo" },
152 );
153 // Statically-rendered page: prerendered into the ISR cache at build
154 // time, served by the server function through the S3 incremental
155 // cache — proves the cache seed upload and the cache-bucket IAM.
156 yield* expectUrlContains(
157 `${url}/static`,
158 "NEXTJS_AWS_STATIC_MARKER",
159 {
160 label: "static (prerendered) page",
161 },
162 );
163 // Public file served from S3 via the KV file manifest.
164 yield* expectUrlContains(
165 `${url}/robots.txt`,
166 "nextjs-aws-robots-marker",
167 {
168 label: "public asset from S3",
169 },
170 );
171 // Client asset (hashed chunk) served from S3: the SSR page links
172 // /_next/static/* files uploaded by the asset deployment.
173 expect(deployed.site.files?.files).toBeDefined();
174
175 // Image optimization: /_next/image is routed by the edge function
176 // to the dedicated optimizer Lambda, which loads the original from
177 // the asset bucket and returns an optimized image.
178 const client = yield* HttpClient.HttpClient;
179 const imageUrl = `${url}/_next/image?url=%2Fpixel.png&w=64&q=75`;
180 const imageResponse = yield* client.get(imageUrl).pipe(
181 Effect.filterOrFail(
182 (response): boolean => response.status === 200,
183 (response) =>
184 new Error(`/_next/image returned status ${response.status}`),
185 ),
186 Effect.retry({
187 schedule: Schedule.exponential("2 seconds"),
188 times: 8,
189 }),
190 );
191 expect(imageResponse.status).toBe(200);
192 expect(imageResponse.headers["content-type"] ?? "").toMatch(
193 /^image\//,
194 );
195
196 const distributionId = deployed.site.distribution!.distributionId;
197
198 if (!process.env.NO_DESTROY) {
199 yield* stack.destroy();
200 yield* assertDistributionDeleted(distributionId);
201 }
202 }),
203 { timeout: 2_400_000 },
204 );
205 },
206);
207
208const assertDistributionDeleted = (distributionId: string) =>
209 cloudfront.getDistribution({ Id: distributionId }).pipe(
210 Effect.flatMap(() => Effect.fail(new Error("DistributionStillExists"))),
211 Effect.catchTag("NoSuchDistribution", () => Effect.void),
212 Effect.retry({
213 while: (error): boolean =>
214 error instanceof Error && error.message === "DistributionStillExists",
215 schedule: Schedule.max([
216 Schedule.fixed("10 seconds"),
217 Schedule.recurs(60),
218 ]),
219 }),
220 );