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 "sveltekit-app",
21);
22
23// Clone under the alchemy package so `@sveltejs/kit`/`svelte`/`vite`
24// resolve from the workspace's hoisted node_modules (the fixture has no
25// node_modules).
26const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
27
28const fixtureEntries = [".gitignore", "package.json", "src", "static"];
29
30// Skipped under the floci runner: in dev the composite deploys only the
31// framework dev server (no Lambda/S3/CloudFront), so this test's live
32// topology assertions are meaningless there. Dev behavior is covered by
33// the co-located SvelteKit.local.test.ts suite.
34const runEmulated = process.env.ALCHEMY_TEST_DEV === "1";
35
36describe.skipIf(!runLive || runEmulated)(
37 "AWS.Website.SvelteKit",
38 {
39 tags: [
40 "provider:aws",
41 "provider:aws:cloudfront",
42 "provider:aws:website",
43 "live",
44 ],
45 },
46 () => {
47 test.provider(
48 "deploys SSR on a streaming Lambda URL with S3 assets behind CloudFront",
49 (stack) =>
50 Effect.gen(function* () {
51 yield* stack.destroy();
52
53 const rootDir = yield* cloneFixture(fixtureDir, {
54 prefix: "alchemy-sveltekit-aws-",
55 tempRoot,
56 entries: fixtureEntries,
57 });
58
59 const deployed = yield* stack.deploy(
60 Effect.gen(function* () {
61 const site = yield* AWS.Website.SvelteKit("SvelteKitSite", {
62 rootDir,
63 forceDestroy: true,
64 invalidation: { paths: "all", wait: true },
65 });
66 return { site };
67 }),
68 );
69
70 const url = deployed.site.url! as string;
71 expect(url).toMatch(/^https:\/\//);
72 expect(deployed.site.serverUrl).toBeDefined();
73 yield* Effect.log(
74 `site url: ${url} | server url: ${deployed.site.serverUrl}`,
75 );
76
77 // The Lambda Function URL serves the SSR page directly — isolates
78 // server-function health from the CloudFront edge routing.
79 yield* expectUrlContains(
80 `${deployed.site.serverUrl!}`,
81 "SVELTEKIT_AWS_PAGE_MARKER",
82 {
83 timeout: "120 seconds",
84 label: "SSR direct from Lambda URL",
85 },
86 );
87
88 // SSR page rendered by the Lambda through CloudFront.
89 yield* expectUrlContains(`${url}/`, "SVELTEKIT_AWS_PAGE_MARKER", {
90 timeout: "180 seconds",
91 label: "SSR home page",
92 });
93 // Server API route through the streaming Function URL origin.
94 yield* expectUrlContains(
95 `${url}/api/hello?echo=roundtrip`,
96 "SVELTEKIT_AWS_API_MARKER",
97 { label: "API route" },
98 );
99 yield* expectUrlContains(
100 `${url}/api/hello?echo=roundtrip`,
101 "roundtrip",
102 { label: "API route query echo" },
103 );
104 // Static file served from S3 via the KV file manifest.
105 yield* expectUrlContains(
106 `${url}/robots.txt`,
107 "sveltekit-aws-robots-marker",
108 {
109 label: "static asset from S3",
110 },
111 );
112 // Prerendered page (kit wrote `prerendered.html` into the assets
113 // directory at build time; the edge router's `.html` fallback
114 // serves it from S3).
115 yield* expectUrlContains(
116 `${url}/prerendered`,
117 "SVELTEKIT_AWS_PRERENDERED_MARKER",
118 { label: "prerendered page" },
119 );
120
121 const distributionId = deployed.site.distribution!.distributionId;
122
123 if (!process.env.NO_DESTROY) {
124 yield* stack.destroy();
125 yield* assertDistributionDeleted(distributionId);
126 }
127 }),
128 { timeout: 2_400_000 },
129 );
130 },
131);
132
133const assertDistributionDeleted = (distributionId: string) =>
134 cloudfront.getDistribution({ Id: distributionId }).pipe(
135 Effect.flatMap(() => Effect.fail(new Error("DistributionStillExists"))),
136 Effect.catchTag("NoSuchDistribution", () => Effect.void),
137 Effect.retry({
138 while: (error): boolean =>
139 error instanceof Error && error.message === "DistributionStillExists",
140 schedule: Schedule.max([
141 Schedule.fixed("10 seconds"),
142 Schedule.recurs(60),
143 ]),
144 }),
145 );