1import * as machines from "@distilled.cloud/fly-io/machines";
2import * as Fly from "@/Fly";
3import { sameStopConfig } from "@/Fly/replicas.ts";
4import * as Test from "@/Test/Alchemy";
5import { expect } from "alchemy-test";
6import * as Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";
8import * as Fiber from "effect/Fiber";
9import * as FileSystem from "effect/FileSystem";
10import * as Path from "effect/Path";
11import { MinimumLogLevel } from "effect/References";
12import * as HttpClient from "effect/http/HttpClient";
13import { createHash } from "node:crypto";
14import { cloneFixture } from "../../Cloudflare/Utils/Fixture.ts";
15import {
16 assertAppGone,
17 assertMachineGone,
18 assertOnlyMachine,
19 getText,
20 initialText,
21 startTraffic,
22 websiteChecks,
23 websiteServices,
24} from "./fixtures/deployment.ts";
25
26const { test } = Test.make({ providers: Fly.providers() });
27const logLevel = Effect.provideService(
28 MinimumLogLevel,
29 process.env.DEBUG ? "Debug" : "Info",
30);
31
32type ResponseRecord = {
33 event: string;
34 version: string;
35 machine: string;
36 managedTimeout: null;
37 payload?: string;
38 startedAt: number;
39 signaledAt: number;
40 completedAt: number;
41 signal: string;
42 activeAtSignal: number;
43 shutdownMs: number;
44};
45
46for (const policy of [
47 {
48 old: "60 seconds",
49 next: "10 seconds",
50 oldMs: 60_000,
51 nextMs: 10_000,
52 delay: 12_000,
53 signal: "SIGTERM",
54 nextSignal: "SIGINT",
55 },
56 {
57 old: "10 seconds",
58 next: "60 seconds",
59 oldMs: 10_000,
60 nextMs: 60_000,
61 delay: 3_000,
62 signal: "SIGINT",
63 nextSignal: "SIGTERM",
64 },
65] as const) {
66 test.provider(
67 `R08/R02 SvelteKit external SSR: v1 to v2, old ${policy.old}/${policy.signal}, new ${policy.next}/${policy.nextSignal}, application-owned response drain`,
68 (stack) =>
69 Effect.gen(function* () {
70 yield* stack.destroy();
71 const fs = yield* FileSystem.FileSystem;
72 const path = yield* Path.Path;
73 const rootDir = yield* cloneFixture(
74 path.resolve(
75 import.meta.dirname,
76 "../../AWS/Website/fixtures/sveltekit-app",
77 ),
78 {
79 prefix: "alchemy-sveltekit-fly-",
80 tempRoot: path.resolve(import.meta.dirname, "../../../.tmp"),
81 entries: [".gitignore", "package.json", "src", "static"],
82 },
83 );
84 const route = path.join(
85 rootDir,
86 "src/routes/api/deployment/[operation]",
87 );
88 yield* fs.makeDirectory(route, { recursive: true });
89 for (const file of ["+server.ts", "state.ts", "version.ts"]) {
90 yield* fs.writeFileString(
91 path.join(route, file),
92 yield* fs.readFileString(
93 path.join(
94 import.meta.dirname,
95 "fixtures/sveltekit/src/routes/api/deployment/[operation]",
96 file,
97 ),
98 ),
99 );
100 }
101 yield* fs.writeFileString(
102 path.join(rootDir, "src/hooks.server.ts"),
103 yield* fs.readFileString(
104 path.join(
105 import.meta.dirname,
106 "fixtures/sveltekit/src/hooks.server.ts",
107 ),
108 ),
109 );
110 const pagePath = path.join(rootDir, "src/routes/+page.server.ts");
111 const page = yield* fs.readFileString(pagePath);
112 const writeVersion = (version: string) =>
113 Effect.gen(function* () {
114 yield* fs.writeFileString(
115 path.join(route, "version.ts"),
116 `export const version: string = ${JSON.stringify(version)};\n`,
117 );
118 yield* fs.writeFileString(
119 pagePath,
120 page.replace(
121 "SVELTEKIT_AWS_PAGE_MARKER",
122 `SVELTEKIT_AWS_PAGE_MARKER ${version}`,
123 ),
124 );
125 });
126 const deploy = (next: boolean) =>
127 stack.deploy(
128 Effect.gen(function* () {
129 const site = yield* Fly.Website.SvelteKit("Web", {
130 rootDir,
131 deploy: { strategy: "bluegreen", healthTimeout: "90 seconds" },
132 shutdown: {
133 signal: next ? policy.nextSignal : policy.signal,
134 timeout: next ? policy.next : policy.old,
135 },
136 checks: websiteChecks("/api/deployment/ready"),
137 services: websiteServices("/api/deployment/ready"),
138 env: {
139 WEBSITE_SHUTDOWN_MS: String(
140 next ? policy.nextMs : policy.oldMs,
141 ),
142 WEBSITE_AFTER_SIGNAL_MS: String(next ? 1000 : policy.delay),
143 },
144 memo: { include: ["src/**", "static/**", "package.json"] },
145 });
146 return { site };
147 }),
148 );
149 let appName: string | undefined;
150 yield* Effect.gen(function* () {
151 yield* writeVersion("v1");
152 const first = yield* deploy(false);
153 appName = first.site.app!.appName;
154 const oldId = first.site.service!.machineId;
155 const url = first.site.url!;
156 expect(url).toBe(`https://${appName}.fly.dev`);
157 expect(yield* initialText(`${url}/`)).toContain(
158 "SVELTEKIT_AWS_PAGE_MARKER v1",
159 );
160 expect(yield* getText(`${url}/api/hello?echo=roundtrip`)).toContain(
161 "SVELTEKIT_AWS_API_MARKER",
162 );
163 expect(
164 JSON.parse(yield* getText(`${url}/api/deployment/version`)),
165 ).toEqual({ version: "v1", machine: oldId, managedTimeout: null });
166 const oldMachine = yield* machines.getMachine({
167 app_name: appName,
168 machine_id: oldId,
169 });
170 expect(oldMachine.config?.checks?.website?.path).toBe(
171 "/api/deployment/ready",
172 );
173 expect(oldMachine.config?.services?.[0]?.checks?.[0]?.path).toBe(
174 "/api/deployment/ready",
175 );
176 expect(oldMachine.config?.stop_config?.signal).toBe(policy.signal);
177 expect(
178 sameStopConfig(oldMachine.config?.stop_config, {
179 signal: policy.signal,
180 timeout: `${policy.oldMs}ms`,
181 }),
182 ).toBe(true);
183 expect(
184 oldMachine.config?.env?.ALCHEMY_FLY_SHUTDOWN_TIMEOUT_MS,
185 ).toBeUndefined();
186
187 const slowStarted = yield* Deferred.make<void>();
188 const streamStarted = yield* Deferred.make<void>();
189 const slowRequest = (
190 operation: string,
191 started: Deferred.Deferred<void>,
192 ) =>
193 HttpClient.get(`${url}/api/deployment/${operation}`, {
194 headers: { connection: "close" },
195 }).pipe(
196 Effect.tap((response) =>
197 Effect.gen(function* () {
198 expect(response.status).toBe(200);
199 expect(response.headers["x-website-machine"]).toBe(oldId);
200 expect(response.headers["x-website-version"]).toBe("v1");
201 yield* Deferred.succeed(started, undefined);
202 }),
203 ),
204 Effect.flatMap((response) => response.text),
205 Effect.timeout("10 minutes"),
206 );
207 const slow = yield* slowRequest("slow", slowStarted).pipe(
208 Effect.forkScoped,
209 );
210 const streamed = yield* slowRequest("stream", streamStarted).pipe(
211 Effect.forkScoped,
212 );
213 yield* Effect.all(
214 [Deferred.await(slowStarted), Deferred.await(streamStarted)],
215 { concurrency: "unbounded" },
216 ).pipe(Effect.timeout("20 seconds"));
217 const traffic = yield* startTraffic(`${url}/api/deployment/version`);
218 yield* traffic.waitFor((body) => JSON.parse(body).version === "v1");
219 yield* writeVersion("v2");
220 const second = yield* deploy(true);
221 const newId = second.site.service!.machineId;
222 expect(second.site.url).toBe(url);
223 expect(newId).not.toBe(oldId);
224 const slowResult = JSON.parse(
225 yield* Fiber.join(slow),
226 ) as ResponseRecord;
227 const streamBody = yield* Fiber.join(streamed);
228 const records = streamBody
229 .trim()
230 .split("\n")
231 .map((line) => JSON.parse(line) as ResponseRecord);
232 expect(records[0].event).toBe("started");
233 expect(
234 records.filter((record) => record.event === "finished"),
235 ).toHaveLength(1);
236 expect(records[records.length - 1].event).toBe("finished");
237 const completed = records[records.length - 1];
238 const payload = records
239 .map((record) => record.payload ?? "")
240 .join("");
241 const expected = "website-v1\n".repeat(8192);
242 const hashes = yield* Effect.sync(() =>
243 [payload, expected].map((value) =>
244 createHash("sha256").update(value).digest("hex"),
245 ),
246 );
247 expect(payload.length).toBe(expected.length);
248 expect(hashes[0]).toBe(hashes[1]);
249 for (const result of [slowResult, completed]) {
250 expect(result.version).toBe("v1");
251 expect(result.machine).toBe(oldId);
252 expect(result.managedTimeout).toBeNull();
253 expect(result.signal).toBe(policy.signal);
254 expect(result.shutdownMs).toBe(policy.oldMs);
255 expect(result.activeAtSignal).toBe(2);
256 expect(result.signaledAt).toBeGreaterThan(result.startedAt);
257 expect(
258 result.completedAt - result.signaledAt,
259 ).toBeGreaterThanOrEqual(policy.delay);
260 expect(result.completedAt - result.signaledAt).toBeLessThan(
261 policy.oldMs,
262 );
263 }
264 expect(yield* getText(`${url}/`)).toContain(
265 "SVELTEKIT_AWS_PAGE_MARKER v2",
266 );
267 yield* traffic.waitFor((body) => JSON.parse(body).version === "v2");
268 const samples = yield* traffic.finish;
269 for (const body of samples) {
270 const sample = JSON.parse(body);
271 expect(sample).toEqual({
272 version: sample.version,
273 machine: sample.version === "v1" ? oldId : newId,
274 managedTimeout: null,
275 });
276 expect(["v1", "v2"]).toContain(sample.version);
277 }
278 const newMachine = yield* machines.getMachine({
279 app_name: appName,
280 machine_id: newId,
281 });
282 expect(newMachine.config?.stop_config?.signal).toBe(
283 policy.nextSignal,
284 );
285 expect(
286 sameStopConfig(newMachine.config?.stop_config, {
287 signal: policy.nextSignal,
288 timeout: `${policy.nextMs}ms`,
289 }),
290 ).toBe(true);
291 expect(
292 newMachine.config?.env?.ALCHEMY_FLY_SHUTDOWN_TIMEOUT_MS,
293 ).toBeUndefined();
294 yield* assertMachineGone(appName, oldId);
295 yield* assertOnlyMachine(appName, newId);
296 }).pipe(
297 Effect.scoped,
298 Effect.ensuring(
299 Effect.gen(function* () {
300 yield* stack.destroy();
301 if (appName) yield* assertAppGone(appName);
302 }).pipe(Effect.orDie),
303 ),
304 );
305 }).pipe(logLevel),
306 // Includes two SSR/image builds, real registry pushes, old-policy drain and cleanup.
307 {
308 tags: [
309 "provider:fly",
310 "provider:fly:machine",
311 "provider:fly:website",
312 "live",
313 ],
314 timeout: 900_000,
315 },
316 );
317}