1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment.ts";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import { isLocalId } from "@/Cloudflare/LocalRuntime.ts";
4import * as Alchemy from "@/index.ts";
5import * as Test from "@/Test/Alchemy";
6import * as kv from "@distilled.cloud/cloudflare/kv";
7import { describe, expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Path from "effect/Path";
11import { MinimumLogLevel } from "effect/References";
12import * as Schedule from "effect/Schedule";
13import * as Stream from "effect/Stream";
14import * as HttpClient from "effect/http/HttpClient";
15import * as HttpClientRequest from "effect/http/HttpClientRequest";
16import * as pathe from "pathe";
17import { cloneFixture } from "../Utils/Fixture.ts";
18import { expectUrlContains } from "../Utils/Http.ts";
19
20const { test } = Test.make({ providers: Cloudflare.providers(), dev: true });
21
22const logLevel = Effect.provideService(
23 MinimumLogLevel,
24 process.env.DEBUG ? "Debug" : "Info",
25);
26
27const fixtureDir = pathe.resolve(
28 import.meta.dirname,
29 "fixtures",
30 "sveltekit-app",
31);
32const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
33
34/**
35 * PUT a text body to `url`, retrying until the dev server serves 200.
36 * Kit's CSRF protection 403s form-like content types (`text/plain`
37 * included) on non-GET requests unless the `origin` header matches the
38 * site, so the site origin is sent explicitly.
39 */
40const putTextReady = (url: string, body: string) =>
41 Effect.gen(function* () {
42 const client = yield* HttpClient.HttpClient;
43 yield* client
44 .execute(
45 HttpClientRequest.put(url).pipe(
46 HttpClientRequest.setHeaders({ origin: new URL(url).origin }),
47 HttpClientRequest.bodyText(body, "text/plain"),
48 ),
49 )
50 .pipe(
51 Effect.flatMap((res) =>
52 res.status === 200
53 ? Effect.void
54 : Effect.fail(new Error(`PUT not ready: ${res.status}`)),
55 ),
56 Effect.retry({
57 schedule: Schedule.min([
58 Schedule.exponential("500 millis"),
59 Schedule.spaced("4 seconds"),
60 ]),
61 times: 15,
62 }),
63 );
64 });
65
66const fetchJsonReady = <T>(url: string) =>
67 Effect.gen(function* () {
68 const client = yield* HttpClient.HttpClient;
69 return yield* client.get(url).pipe(
70 Effect.flatMap((res) =>
71 res.status === 200
72 ? Effect.flatMap(res.text, (body) =>
73 Effect.try({
74 try: () => JSON.parse(body) as T,
75 catch: () => new Error(`non-json body: ${body}`),
76 }),
77 )
78 : Effect.fail(new Error(`dev server not ready: ${res.status}`)),
79 ),
80 Effect.retry({
81 schedule: Schedule.min([
82 Schedule.exponential("500 millis"),
83 Schedule.spaced("4 seconds"),
84 ]),
85 times: 15,
86 }),
87 );
88 });
89
90// Concurrent like the other Website suites: the kit dev server never
91// chdirs (the kit-runtime realign plugin handles cwd != root), so
92// nothing here mutates process-global state.
93describe.concurrent(
94 "SvelteKit dev",
95 { tags: ["provider:cloudflare", "provider:cloudflare:website"] },
96 () => {
97 test.provider(
98 "SvelteKit dev: local dev server renders SSR with platform.env bindings, local KV, and HMR",
99 (stack) =>
100 Effect.gen(function* () {
101 yield* stack.destroy();
102
103 const rootDir = yield* cloneFixture(fixtureDir, {
104 prefix: "alchemy-sveltekit-dev-",
105 tempRoot,
106 entries: ["package.json", "src", "static"],
107 });
108
109 const bindingMarker = "sveltekit-dev-binding-marker";
110
111 const { site, siteKv } = yield* stack.deploy(
112 Effect.gen(function* () {
113 // A locally-emulated KV namespace bound into `platform.env` —
114 // no cloud call runs for it in dev.
115 const siteKv = yield* Cloudflare.KV.Namespace("SiteKV");
116 const site = yield* Cloudflare.Website.SvelteKit(
117 "SvelteKitLocal",
118 {
119 rootDir,
120 dev: { port: 0 },
121 memo: { include: ["src/**", "static/**", "package.json"] },
122 env: {
123 TEST_BINDING: bindingMarker,
124 SITE_KV: siteKv,
125 },
126 },
127 );
128 return { site, siteKv };
129 }),
130 );
131
132 // Local identity: the url points at the alchemy dev proxy — no
133 // cloud Worker exists — and the namespace id carries the `dev:`
134 // marker (proof no cloud call ran for it).
135 expect(site.url).toBeDefined();
136 expect(site.url).toMatch(/^http:\/\/localhost:\d+/);
137 expect(isLocalId(siteKv.namespaceId)).toBe(true);
138 expect(siteKv.namespaceId).toMatch(/^dev:/);
139
140 // SSR route: kit's own Vite dev server (Node SSR), with
141 // `platform.env` served by the cloudflare-runtime platform proxy
142 // and the literal env overlaid.
143 yield* expectUrlContains(
144 `${site.url!}/`,
145 `binding:${bindingMarker}`,
146 {
147 timeout: "120 seconds",
148 label: "sveltekit dev SSR home with platform.env binding",
149 },
150 );
151
152 // API endpoint: node:crypto + platform.env through the dev server.
153 yield* expectUrlContains(
154 `${site.url!}/api/hello`,
155 `"binding":"${bindingMarker}"`,
156 {
157 timeout: "60 seconds",
158 label: "sveltekit dev API route",
159 },
160 );
161
162 // Static asset from `static/`.
163 yield* expectUrlContains(`${site.url!}/robots.txt`, "User-agent", {
164 timeout: "60 seconds",
165 label: "sveltekit dev static asset",
166 });
167
168 // ── local KV round-trip: the /api/kv routes write and read
169 // `platform.env.SITE_KV`, backed by the local simulator ─────────────
170 const kvValue = "sveltekit-dev-kv-value";
171 yield* putTextReady(`${site.url!}/api/kv?key=greeting`, kvValue);
172 const kvRead = yield* fetchJsonReady<{ value: string | null }>(
173 `${site.url!}/api/kv?key=greeting`,
174 );
175 expect(kvRead.value).toBe(kvValue);
176
177 // ── HMR: edit the home route in place; kit's dev server hot-reloads
178 // and the dev URL serves the new markup without a redeploy ──────────
179 const fs = yield* FileSystem.FileSystem;
180 const path = yield* Path.Path;
181 const pagePath = path.join(rootDir, "src", "routes", "+page.svelte");
182 const page = yield* fs.readFileString(pagePath);
183 yield* fs.writeFileString(
184 pagePath,
185 page.replace("SvelteKit SSR home", "SvelteKit SSR home v2-marker"),
186 );
187
188 // Bounded poll: expectUrlContains retries with a capped backoff and
189 // a hard total timeout.
190 yield* expectUrlContains(`${site.url!}/`, "v2-marker", {
191 timeout: "90 seconds",
192 label: "sveltekit dev HMR-updated home page",
193 });
194
195 // The KV binding survives the hot reload.
196 const kvReadAfterHmr = yield* fetchJsonReady<{
197 value: string | null;
198 }>(`${site.url!}/api/kv?key=greeting`);
199 expect(kvReadAfterHmr.value).toBe(kvValue);
200
201 yield* stack.destroy();
202 }).pipe(logLevel),
203 { tags: ["provider:cloudflare:kv", "local"], timeout: 300_000 },
204 );
205
206 /**
207 * `Alchemy.remote()` opts the KV namespace OUT of local emulation: even
208 * under `alchemy dev` it is created on real Cloudflare and the dev
209 * server's `platform.env.SITE_KV` proxies to it remotely. Out-of-band
210 * reads through the cloud API prove the dev server's write landed in the
211 * real namespace, and destroy deletes it from the cloud (the state row is
212 * stamped live).
213 */
214 test.provider(
215 "SvelteKit dev: Alchemy.remote() KV namespace runs live behind the dev server",
216 (stack) =>
217 Effect.gen(function* () {
218 yield* stack.destroy();
219
220 const rootDir = yield* cloneFixture(fixtureDir, {
221 prefix: "alchemy-sveltekit-dev-remote-",
222 tempRoot,
223 entries: ["package.json", "src", "static"],
224 });
225
226 const { site, siteKv } = yield* stack.deploy(
227 Effect.gen(function* () {
228 const siteKv = yield* Cloudflare.KV.Namespace(
229 "RemoteSiteKV",
230 ).pipe(Alchemy.remote());
231 const site = yield* Cloudflare.Website.SvelteKit(
232 "SvelteKitLocalRemoteKV",
233 {
234 rootDir,
235 dev: { port: 0 },
236 memo: { include: ["src/**", "static/**", "package.json"] },
237 env: {
238 TEST_BINDING: "sveltekit-dev-remote-marker",
239 SITE_KV: siteKv,
240 },
241 },
242 );
243 return { site, siteKv };
244 }),
245 );
246
247 // The remote() namespace is REAL — a cloud id, not a `dev:` one —
248 // while the site itself still serves from the local dev proxy.
249 expect(site.url).toMatch(/^http:\/\/localhost:\d+/);
250 expect(isLocalId(siteKv.namespaceId)).toBe(false);
251 expect(siteKv.namespaceId).not.toMatch(/^dev:/);
252
253 // Write through the dev server's remote-proxied binding.
254 const kvValue = "sveltekit-dev-remote-kv-value";
255 yield* putTextReady(`${site.url!}/api/kv?key=remote-key`, kvValue);
256 const kvRead = yield* fetchJsonReady<{ value: string | null }>(
257 `${site.url!}/api/kv?key=remote-key`,
258 );
259 expect(kvRead.value).toBe(kvValue);
260
261 // Out-of-band: the write is visible through the cloud API — the
262 // binding really hit the live namespace.
263 const { accountId } = yield* yield* CloudflareEnvironment;
264 const cloudValue = yield* kv
265 .getNamespaceValue({
266 accountId,
267 namespaceId: siteKv.namespaceId,
268 keyName: "remote-key",
269 })
270 .pipe(
271 Effect.flatMap((res) =>
272 Effect.tryPromise(() =>
273 new Response(
274 Stream.toReadableStream(res.body) as BodyInit,
275 ).text(),
276 ),
277 ),
278 Effect.retry({
279 schedule: Schedule.exponential("1 second", 1.5),
280 times: 5,
281 }),
282 );
283 expect(cloudValue).toBe(kvValue);
284
285 yield* stack.destroy();
286
287 // The live namespace was deleted from the cloud on destroy (its
288 // state row is stamped live, so the live provider handles the
289 // delete even in a dev run).
290 const gone = yield* kv
291 .getNamespace({
292 accountId,
293 namespaceId: siteKv.namespaceId,
294 })
295 .pipe(
296 Effect.as(false),
297 Effect.catchTag("NamespaceNotFound", () => Effect.succeed(true)),
298 );
299 expect(gone).toBe(true);
300 }).pipe(logLevel),
301 { tags: ["provider:cloudflare:kv", "live"], timeout: 300_000 },
302 );
303
304 // ─────────────────────────────────────────────────────────────────────
305 // SPA fixture under `alchemy dev`: pages are `ssr = false`, so a deep
306 // link to a client route must serve the app shell (kit's Vite dev
307 // server renders app.html with no page markup), while `+server.ts`
308 // endpoints still execute server-side in the dev server with access to
309 // `platform.env`.
310 // ─────────────────────────────────────────────────────────────────────
311
312 const spaFixtureDir = pathe.resolve(
313 import.meta.dirname,
314 "fixtures",
315 "sveltekit-spa-app",
316 );
317
318 /** app.html marker in the SPA fixture — identifies the app shell. */
319 const SPA_SHELL_MARKER = "sveltekit-spa-shell";
320
321 test.provider(
322 "SvelteKit dev: SPA deep links serve the app shell; server endpoints still run with platform.env bindings",
323 (stack) =>
324 Effect.gen(function* () {
325 yield* stack.destroy();
326
327 const rootDir = yield* cloneFixture(spaFixtureDir, {
328 prefix: "alchemy-sveltekit-dev-spa-",
329 tempRoot,
330 entries: ["package.json", "src"],
331 });
332
333 const bindingMarker = "sveltekit-dev-spa-binding-marker";
334
335 const site = yield* stack.deploy(
336 Effect.gen(function* () {
337 return yield* Cloudflare.Website.SvelteKit("SvelteKitSpaLocal", {
338 rootDir,
339 dev: { port: 0 },
340 memo: { include: ["src/**", "package.json"] },
341 // Same props as the live SPA test — in dev the build-time
342 // fallback generation never runs (no build); kit's dev
343 // server serves the shell for `ssr = false` routes itself.
344 assets: { notFoundHandling: "single-page-application" },
345 env: {
346 TEST_BINDING: bindingMarker,
347 },
348 });
349 }),
350 );
351
352 // Local identity: the url points at the alchemy dev proxy — no
353 // cloud Worker exists.
354 expect(site.url).toBeDefined();
355 expect(site.url).toMatch(/^http:\/\/localhost:\d+/);
356
357 // (a) A deep link to a CLIENT route serves the app shell — the
358 // shell marker is present and the widget markup (which only ever
359 // renders in the browser) is absent.
360 const widgetsBody = yield* expectUrlContains(
361 `${site.url!}/widgets`,
362 SPA_SHELL_MARKER,
363 {
364 timeout: "120 seconds",
365 label: "sveltekit dev SPA deep link serves the shell",
366 },
367 );
368 expect(widgetsBody).not.toContain("sveltekit-spa-widgets");
369 expect(widgetsBody).not.toContain("sprocket");
370
371 // (b) The server endpoint executes in the dev server with the
372 // binding overlaid on `platform.env`.
373 const widgets = yield* fetchJsonReady<{
374 server: boolean;
375 message: string | null;
376 widgets: Array<{ id: string; name: string }>;
377 }>(`${site.url!}/api/widgets`);
378 expect(widgets.server).toBe(true);
379 expect(widgets.message).toBe(bindingMarker);
380 expect(widgets.widgets.map((w) => w.name)).toEqual([
381 "sprocket",
382 "flange",
383 "grommet",
384 ]);
385
386 yield* stack.destroy();
387 }).pipe(logLevel),
388 { tags: ["local"], timeout: 300_000 },
389 );
390 },
391);