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 * as Schedule from "effect/Schedule";
7import * as pathe from "pathe";
8import { cloneFixture } from "../../Cloudflare/Utils/Fixture.ts";
9import { expectUrlContains } from "../../Cloudflare/Utils/Http.ts";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13// Gated with the rest of the AWS.Website suites: the CloudFront lifecycle
14// dominates the runtime (create ~5-15 min, destroy ~5-15 min).
15const runLive = !process.env.FAST;
16
17const fixtureDir = pathe.resolve(import.meta.dirname, "fixtures", "nuxt-app");
18
19// Clone under the alchemy package so `nuxt`/`nitropack` resolve from the
20// workspace's hoisted node_modules (the fixture has no node_modules).
21const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
22
23const fixtureEntries = [
24 ".gitignore",
25 "package.json",
26 "nuxt.config.ts",
27 "app",
28 "server",
29 "public",
30];
31
32// Under the floci runner the standalone composite deploys only the framework
33// dev server (no Lambda/S3/CloudFront), so the standalone test below is
34// live-only; the shared-Router test runs in both modes because the Router
35// pipeline deploys fully against the emulator, which serves the router's
36// edge on a local plain-HTTP port.
37const runEmulated = process.env.ALCHEMY_TEST_DEV === "1";
38
39describe.skipIf(!runLive)(
40 "AWS.Website.Nuxt",
41 {
42 tags: [
43 "provider:aws",
44 "provider:aws:cloudfront",
45 "provider:aws:website",
46 "live",
47 ],
48 },
49 () => {
50 test.provider.skipIf(runEmulated)(
51 "deploys SSR on a streaming Lambda URL with S3 assets behind CloudFront",
52 (stack) =>
53 Effect.gen(function* () {
54 yield* stack.destroy();
55
56 const rootDir = yield* cloneFixture(fixtureDir, {
57 prefix: "alchemy-nuxt-aws-",
58 tempRoot,
59 entries: fixtureEntries,
60 });
61
62 const deployed = yield* stack.deploy(
63 Effect.gen(function* () {
64 const site = yield* AWS.Website.Nuxt("NuxtSite", {
65 rootDir,
66 forceDestroy: true,
67 invalidation: { paths: "all", wait: true },
68 });
69 return { site };
70 }),
71 );
72
73 const url = deployed.site.url! as string;
74 expect(url).toMatch(/^https:\/\//);
75 expect(deployed.site.serverUrl).toBeDefined();
76 yield* Effect.log(
77 `site url: ${url} | server url: ${deployed.site.serverUrl}`,
78 );
79
80 // The Lambda Function URL serves the SSR page directly — isolates
81 // server-function health from the CloudFront edge routing.
82 yield* expectUrlContains(
83 `${deployed.site.serverUrl!}`,
84 "NUXT_AWS_PAGE_MARKER",
85 {
86 timeout: "120 seconds",
87 label: "SSR direct from Lambda URL",
88 },
89 );
90
91 // SSR page rendered by the Lambda through CloudFront.
92 yield* expectUrlContains(`${url}/`, "NUXT_AWS_PAGE_MARKER", {
93 timeout: "180 seconds",
94 label: "SSR home page",
95 });
96 // The fixture's own nuxt.config.ts applied (runtimeConfig marker).
97 yield* expectUrlContains(
98 `${url}/`,
99 "config:nuxt-aws-user-config-loaded",
100 {
101 label: "user nuxt.config.ts applied",
102 },
103 );
104 // Server API route through the streaming Function URL origin.
105 yield* expectUrlContains(
106 `${url}/api/hello?echo=roundtrip`,
107 "NUXT_AWS_API_MARKER",
108 { label: "API route" },
109 );
110 yield* expectUrlContains(
111 `${url}/api/hello?echo=roundtrip`,
112 "roundtrip",
113 { label: "API route query echo" },
114 );
115 // Public file served from S3 via the KV file manifest.
116 yield* expectUrlContains(
117 `${url}/robots.txt`,
118 "nuxt-aws-robots-marker",
119 {
120 label: "public asset from S3",
121 },
122 );
123 // Prerendered page (nitro wrote it into .output/public at build
124 // time; the edge router serves it from S3 by exact match).
125 yield* expectUrlContains(
126 `${url}/prerendered`,
127 "NUXT_AWS_PRERENDERED_MARKER",
128 { label: "prerendered page" },
129 );
130
131 const distributionId = deployed.site.distribution!.distributionId;
132
133 if (!process.env.NO_DESTROY) {
134 yield* stack.destroy();
135 yield* assertDistributionDeleted(distributionId);
136 }
137 }),
138 { timeout: 2_400_000 },
139 );
140
141 test.provider(
142 "serves SSR through a shared Router distribution with Lambda env applied",
143 (stack) =>
144 Effect.gen(function* () {
145 yield* stack.destroy();
146
147 const rootDir = yield* cloneFixture(fixtureDir, {
148 prefix: "alchemy-nuxt-aws-router-",
149 tempRoot,
150 entries: fixtureEntries,
151 });
152
153 const deployed = yield* stack.deploy(
154 Effect.gen(function* () {
155 const router = yield* AWS.Website.Router("FrontDoor", {
156 invalidation: { paths: "all", wait: true },
157 });
158 const site = yield* AWS.Website.Nuxt("NuxtSite", {
159 rootDir,
160 forceDestroy: true,
161 domain: { router },
162 env: {
163 NUXT_PUBLIC_ENV_MARKER: "nuxt-aws-live-env-marker",
164 },
165 });
166 return { router, site };
167 }),
168 );
169
170 const url = deployed.router.url as string;
171 // `https://{id}.cloudfront.net` live; the emulator serves the
172 // router's edge on a local plain-HTTP port.
173 expect(url).toMatch(
174 runEmulated ? /^http:\/\/localhost:\d+/ : /^https:\/\//,
175 );
176
177 // SSR through the ROUTER's distribution (the site registered
178 // itself in the router's KV store — no site-owned distribution).
179 expect(deployed.site.distribution).toBeUndefined();
180 // Generous budget: a fresh router distribution's KVS association +
181 // function propagation can lag past 180s on first serve.
182 yield* expectUrlContains(`${url}/`, "NUXT_AWS_PAGE_MARKER", {
183 timeout: "300 seconds",
184 label: "SSR via router",
185 });
186 // Lambda env applied on deploy (parity with the dev-server
187 // injection asserted in Nuxt.local.test.ts).
188 yield* expectUrlContains(`${url}/`, "env:nuxt-aws-live-env-marker", {
189 label: "server.environment on the Lambda",
190 });
191 // The router's defaultTTL-0 cache policy must not cache SSR
192 // responses: two fetches of an SSR page with a per-request value
193 // would prove staleness, but the page is deterministic — instead
194 // assert the API route (dynamic, no cache-control) round-trips
195 // with distinct query strings, which a day-long cached body under
196 // CachingOptimized would break.
197 yield* expectUrlContains(
198 `${url}/api/hello?echo=router-one`,
199 "router-one",
200 { label: "API via router (query one)" },
201 );
202 yield* expectUrlContains(
203 `${url}/api/hello?echo=router-two`,
204 "router-two",
205 { label: "API via router (query two)" },
206 );
207 // Static asset from S3 through the router's edge function.
208 yield* expectUrlContains(
209 `${url}/robots.txt`,
210 "nuxt-aws-robots-marker",
211 {
212 label: "public asset via router",
213 },
214 );
215 // Prerendered page from S3 by exact match.
216 yield* expectUrlContains(
217 `${url}/prerendered`,
218 "NUXT_AWS_PRERENDERED_MARKER",
219 { label: "prerendered page via router" },
220 );
221
222 const distributionId = deployed.router.distributionId as string;
223
224 if (!process.env.NO_DESTROY) {
225 yield* stack.destroy();
226 yield* assertDistributionDeleted(distributionId);
227 }
228 }),
229 { timeout: 2_400_000 },
230 );
231 },
232);
233
234const assertDistributionDeleted = (distributionId: string) =>
235 cloudfront.getDistribution({ Id: distributionId }).pipe(
236 Effect.flatMap(() => Effect.fail(new Error("DistributionStillExists"))),
237 Effect.catchTag("NoSuchDistribution", () => Effect.void),
238 Effect.retry({
239 while: (error): boolean =>
240 error instanceof Error && error.message === "DistributionStillExists",
241 schedule: Schedule.max([
242 Schedule.fixed("10 seconds"),
243 Schedule.recurs(60),
244 ]),
245 }),
246 );