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