1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import * as Test from "@/Test/Alchemy";
4import * as kv from "@distilled.cloud/cloudflare/kv";
5import { describe, expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12import * as Stream from "effect/Stream";
13import * as HttpClient from "effect/http/HttpClient";
14import * as HttpClientRequest from "effect/http/HttpClientRequest";
15import * as pathe from "pathe";
16import { cloneFixture } from "../Utils/Fixture.ts";
17import { expectUrlContains, expectUrlRedirect } from "../Utils/Http.ts";
18import {
19 expectWorkerExists,
20 waitForWorkerToBeDeleted,
21} from "../Utils/Worker.ts";
22
23const { test } = Test.make({ providers: Cloudflare.providers() });
24
25const logLevel = Effect.provideService(
26 MinimumLogLevel,
27 process.env.DEBUG ? "Debug" : "Info",
28);
29
30const fixtureDir = pathe.resolve(
31 import.meta.dirname,
32 "fixtures",
33 "sveltekit-app",
34);
35
36// Keep the temp clone under the alchemy package (same convention as the
37// Vite tests) so the project root stays representable relative to cwd.
38const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
39
40const svelteKitProps = (rootDir: string) => ({
41 rootDir,
42 workersDev: { enabled: true, previewsEnabled: true },
43 memo: { include: ["src/**", "static/**", "package.json"] },
44});
45
46// Concurrent like the other Website suites: the SvelteKit source build
47// runs in a child process with cwd = project root (core/BuildChild.ts),
48// so nothing here mutates process-global state.
49describe.concurrent(
50 "SvelteKit",
51 {
52 tags: [
53 "provider:cloudflare",
54 "provider:cloudflare:website",
55 "provider:cloudflare:worker",
56 "live",
57 ],
58 },
59 () => {
60 test.provider(
61 "SvelteKit: deploys SSR + bindings + static assets and memoizes unchanged rebuilds",
62 (stack) =>
63 Effect.gen(function* () {
64 const { accountId } = yield* yield* CloudflareEnvironment;
65
66 yield* stack.destroy();
67
68 const rootDir = yield* cloneFixture(fixtureDir, {
69 prefix: "alchemy-sveltekit-",
70 tempRoot,
71 entries: ["package.json", "src", "static"],
72 });
73
74 const bindingMarker = "sveltekit-binding-marker";
75
76 const deploy = () =>
77 stack.deploy(
78 Effect.gen(function* () {
79 // A REAL KV namespace bound into `platform.env.SITE_KV` — the
80 // /api/kv routes round-trip it server-side.
81 const siteKv = yield* Cloudflare.KV.Namespace("SiteKV");
82 const site = yield* Cloudflare.Website.SvelteKit(
83 "SvelteKitSite",
84 {
85 ...svelteKitProps(rootDir),
86 env: {
87 TEST_BINDING: bindingMarker,
88 SITE_KV: siteKv,
89 },
90 },
91 );
92 return { site, siteKv };
93 }),
94 );
95
96 const { site: site1, siteKv } = yield* deploy();
97
98 expect(site1.url).toBeDefined();
99 expect(site1.hash?.input).toBeDefined();
100 yield* expectWorkerExists(site1.workerName, accountId);
101
102 // SSR route: the server `load` reads `platform.env.TEST_BINDING`.
103 yield* expectUrlContains(
104 `${site1.url!}/`,
105 `binding:${bindingMarker}`,
106 {
107 timeout: "120 seconds",
108 label: "SSR home with platform.env binding",
109 },
110 );
111
112 // API endpoint: node:crypto under nodejs_compat + platform.env.
113 const hello = yield* fetchJsonReady<{
114 uuid: string;
115 binding: string;
116 }>(`${site1.url!}/api/hello`);
117 expect(hello.binding).toBe(bindingMarker);
118 expect(hello.uuid).toMatch(/^[0-9a-f-]{36}$/);
119
120 // Static asset from `static/`.
121 yield* expectUrlContains(`${site1.url!}/robots.txt`, "User-agent", {
122 timeout: "60 seconds",
123 label: "static asset",
124 });
125
126 // Prerendered page served from assets.
127 yield* expectUrlContains(
128 `${site1.url!}/prerendered`,
129 "this-page-is-prerendered",
130 {
131 timeout: "60 seconds",
132 label: "prerendered page",
133 },
134 );
135
136 // ── form action: the first non-GET against an alchemy-deployed
137 // framework worker. The urlencoded POST must route through the asset
138 // layer to the worker, run the `greet` action server-side, and
139 // observe `platform.env` ─────────────────────────────────────────────
140 const formBody = yield* postFormReady(
141 `${site1.url!}/form?/greet`,
142 new URL(site1.url!).origin,
143 { name: "alchemy" },
144 );
145 expect(formBody).toContain("result:hello alchemy");
146 expect(formBody).toContain(`form-binding:${bindingMarker}`);
147
148 // ── real KV binding: PUT writes through `platform.env.SITE_KV`,
149 // GET reads it back, and the cloud API sees the same value ──────────
150 expect(siteKv.namespaceId).toBeDefined();
151 const kvKey = "greeting";
152 const kvValue = `sveltekit-kv-${bindingMarker}`;
153 yield* putTextReady(`${site1.url!}/api/kv?key=${kvKey}`, kvValue);
154 const kvRead = yield* fetchJsonReady<{ value: string | null }>(
155 `${site1.url!}/api/kv?key=${kvKey}`,
156 ).pipe(
157 Effect.filterOrFail(
158 (r) => r.value === kvValue,
159 (r) =>
160 new Error(`kv value not visible yet: ${JSON.stringify(r)}`),
161 ),
162 Effect.retry({
163 schedule: Schedule.exponential("1 second", 1.5),
164 times: 8,
165 }),
166 );
167 expect(kvRead.value).toBe(kvValue);
168
169 // Out-of-band: the worker's write landed in the REAL namespace.
170 const cloudValue = yield* kv
171 .getNamespaceValue({
172 accountId,
173 namespaceId: siteKv.namespaceId,
174 keyName: kvKey,
175 })
176 .pipe(
177 Effect.flatMap((res) =>
178 Effect.tryPromise(() =>
179 new Response(
180 Stream.toReadableStream(res.body) as BodyInit,
181 ).text(),
182 ),
183 ),
184 Effect.retry({
185 schedule: Schedule.exponential("1 second", 1.5),
186 times: 5,
187 }),
188 );
189 expect(cloudValue).toBe(kvValue);
190
191 // ── deploy 2: no changes ⇒ the rebuild-free input hash matches and
192 // the deploy short-circuits without building ─────────────────────────
193 const { site: site2 } = yield* deploy();
194
195 expect(site2.hash?.input).toBeDefined();
196 expect(site2.hash?.input).toEqual(site1.hash?.input);
197 expect(site2.url).toBe(site1.url);
198
199 // ── deploy 3: edit a route ⇒ the input hash busts and the rebuilt
200 // page serves the new content ────────────────────────────────────────
201 const fs = yield* FileSystem.FileSystem;
202 const path = yield* Path.Path;
203 const pagePath = path.join(rootDir, "src", "routes", "+page.svelte");
204 const editedMarker = "sveltekit-edited-marker";
205 const page = yield* fs.readFileString(pagePath);
206 yield* fs.writeFileString(
207 pagePath,
208 page.replace(
209 "SvelteKit SSR home",
210 `SvelteKit SSR home ${editedMarker}`,
211 ),
212 );
213
214 const { site: site3 } = yield* deploy();
215
216 expect(site3.hash?.input).toBeDefined();
217 expect(site3.hash?.input).not.toEqual(site1.hash?.input);
218 yield* expectUrlContains(`${site3.url!}/`, editedMarker, {
219 timeout: "120 seconds",
220 label: "edited SSR home page",
221 });
222
223 yield* stack.destroy();
224 yield* waitForWorkerToBeDeleted(site1.workerName, accountId);
225 // Destroy must also delete the bound KV namespace from the cloud.
226 yield* waitForNamespaceToBeDeleted(siteKv.namespaceId, accountId);
227 }).pipe(logLevel),
228 { tags: ["provider:cloudflare:kv"], timeout: 360_000 },
229 );
230
231 // ─────────────────────────────────────────────────────────────────────
232 // SPA mode: `assets.notFoundHandling: "single-page-application"`
233 //
234 // Pages are `ssr = false`, so deep links and unknown routes must serve
235 // the generated app-shell `index.html` (identified by the fixture's
236 // shell marker), while `+server.ts` endpoints still execute server-side
237 // in the worker with access to `platform.env`.
238 // ─────────────────────────────────────────────────────────────────────
239
240 const spaFixtureDir = pathe.resolve(
241 import.meta.dirname,
242 "fixtures",
243 "sveltekit-spa-app",
244 );
245
246 const SPA_SHELL_MARKER = "sveltekit-spa-shell";
247
248 test.provider(
249 "SvelteKit SPA: deep links and unknown routes serve the app shell; server endpoints still run in the worker",
250 (stack) =>
251 Effect.gen(function* () {
252 const { accountId } = yield* yield* CloudflareEnvironment;
253
254 yield* stack.destroy();
255
256 const rootDir = yield* cloneFixture(spaFixtureDir, {
257 prefix: "alchemy-sveltekit-spa-",
258 tempRoot,
259 entries: ["package.json", "src"],
260 });
261
262 const bindingMarker = "sveltekit-spa-binding-marker";
263
264 const site = yield* stack.deploy(
265 Effect.gen(function* () {
266 return yield* Cloudflare.Website.SvelteKit("SvelteKitSpaSite", {
267 rootDir,
268 workersDev: { enabled: true, previewsEnabled: true },
269 memo: { include: ["src/**", "package.json"] },
270 // The single platform-native knob: `assets.notFoundHandling`
271 // drives both the build-time app-shell fallback generation
272 // and the assets layer that serves it. This deploy pins that
273 // coupling — without it, unknown routes 404 with an empty
274 // body.
275 assets: { notFoundHandling: "single-page-application" },
276 env: {
277 TEST_BINDING: bindingMarker,
278 },
279 });
280 }),
281 );
282
283 expect(site.url).toBeDefined();
284 // SPA mode still ships a worker: the kit server bundle serves the
285 // `+server.ts` endpoints.
286 expect(site.hash?.bundle).toBeDefined();
287 yield* expectWorkerExists(site.workerName, accountId);
288
289 // (a) A deep link to a CLIENT route serves the app shell — the
290 // shell marker is present and the widget markup (which only ever
291 // renders in the browser) is absent.
292 const widgetsBody = yield* expectSpaShell(`${site.url!}/widgets`);
293 expect(widgetsBody).toContain(SPA_SHELL_MARKER);
294 expect(widgetsBody).not.toContain("sveltekit-spa-widgets");
295 expect(widgetsBody).not.toContain("sprocket");
296
297 // (b) A route that doesn't exist at all serves the shell too — the
298 // client router owns 404 handling in SPA mode.
299 const unknownBody = yield* expectSpaShell(
300 `${site.url!}/definitely/not/a/route`,
301 );
302 expect(unknownBody).toContain(SPA_SHELL_MARKER);
303
304 // (c) The server endpoint executes in the worker with the binding.
305 const widgets = yield* fetchJsonReady<{
306 server: boolean;
307 message: string | null;
308 widgets: Array<{ id: string; name: string }>;
309 }>(`${site.url!}/api/widgets`);
310 expect(widgets.server).toBe(true);
311 expect(widgets.message).toBe(bindingMarker);
312 expect(widgets.widgets.map((w) => w.name)).toEqual([
313 "sprocket",
314 "flange",
315 "grommet",
316 ]);
317
318 yield* stack.destroy();
319 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
320 }).pipe(logLevel),
321 { timeout: 360_000 },
322 );
323
324 // ─────────────────────────────────────────────────────────────────────
325 // 404-page mode: `assets.notFoundHandling: "404-page"`
326 //
327 // The build writes a `404.html` fallback asset that always renders the
328 // app shell (so kit's own error page shows), driven by the same
329 // platform-native knob that configures the assets layer serving it.
330 //
331 // Assets `404-page` semantics with a worker present: under the
332 // `assets_navigation_prefers_asset_serving` compat flag (default-on for
333 // compatibility dates >= 2025-04-01; the resource's default date is
334 // later), a navigation-shaped request (`Sec-Fetch-Mode: navigate`) that
335 // matches no asset is answered by the assets layer directly with the
336 // `404.html` body and status 404 — the worker is never invoked. Requests
337 // WITHOUT the navigate header still invoke the worker, where kit renders
338 // its own error page (the shim never defers in 404-page mode —
339 // upstream-parity, see WorkerShim.ts).
340 //
341 // ─────────────────────────────────────────────────────────────────────
342
343 const notFoundFixtureDir = pathe.resolve(
344 import.meta.dirname,
345 "fixtures",
346 "sveltekit-404-app",
347 );
348
349 /** app.html marker — present in every response rendered through app.html. */
350 const SHELL_404_MARKER = "sveltekit-404-shell";
351 /** Root `+error.svelte` marker — present only when KIT renders the 404. */
352 const KIT_ERROR_MARKER = "sveltekit-404-kit-error-page";
353 /** Root page marker — present only when the home page renders. */
354 const HOME_404_MARKER = "sveltekit-404-home";
355
356 test.provider(
357 "SvelteKit 404-page + spa fallback: unmatched navigations serve the app-shell 404.html from assets; matched routes SSR; non-navigations keep kit's own 404",
358 (stack) =>
359 Effect.gen(function* () {
360 const { accountId } = yield* yield* CloudflareEnvironment;
361
362 yield* stack.destroy();
363
364 const rootDir = yield* cloneFixture(notFoundFixtureDir, {
365 prefix: "alchemy-sveltekit-404-spa-",
366 tempRoot,
367 entries: ["package.json", "src"],
368 });
369
370 const bindingMarker = "sveltekit-404-spa-binding-marker";
371
372 const site = yield* stack.deploy(
373 Effect.gen(function* () {
374 return yield* Cloudflare.Website.SvelteKit(
375 "SvelteKit404SpaSite",
376 {
377 rootDir,
378 workersDev: { enabled: true, previewsEnabled: true },
379 memo: { include: ["src/**", "package.json"] },
380 // `404-page` writes `404.html` — the generated page always
381 // renders the app shell now, so kit's own error page shows
382 // on unmatched navigations.
383 assets: { notFoundHandling: "404-page" },
384 env: {
385 TEST_BINDING: bindingMarker,
386 },
387 },
388 );
389 }),
390 );
391
392 expect(site.url).toBeDefined();
393 expect(site.hash?.bundle).toBeDefined();
394 yield* expectWorkerExists(site.workerName, accountId);
395
396 // (a) Matched route SSRs normally: a plain (non-navigation) fetch
397 // reaches the worker and the server `load` observes platform.env.
398 const homeBody = yield* expectUrlContains(
399 `${site.url!}/`,
400 `binding:${bindingMarker}`,
401 {
402 timeout: "120 seconds",
403 label: "404-page spa: SSR home",
404 },
405 );
406 expect(homeBody).toContain(HOME_404_MARKER);
407
408 // (b) An unmatched route on a navigation-shaped request serves the
409 // generated app-shell 404.html from the assets layer with status
410 // 404 — no kit error-page markup (the worker never ran) and no
411 // page markup (the shell render has no page components).
412 const fallbackBody = yield* expectPageResponse(
413 `${site.url!}/definitely/not/a/route`,
414 {
415 label: "404-page spa: assets fallback (navigate)",
416 headers: { accept: "text/html", "sec-fetch-mode": "navigate" },
417 expected: `status 404 with app-shell marker "${SHELL_404_MARKER}" and no "${KIT_ERROR_MARKER}"`,
418 check: (res) =>
419 res.status === 404 &&
420 res.body.includes(SHELL_404_MARKER) &&
421 !res.body.includes(KIT_ERROR_MARKER),
422 },
423 );
424 expect(fallbackBody).not.toContain(HOME_404_MARKER);
425
426 // (c) The same unmatched route WITHOUT `Sec-Fetch-Mode: navigate`
427 // invokes the worker, and kit renders its own error page — the
428 // shim never defers in 404-page mode (upstream parity).
429 const kitErrorBody = yield* expectPageResponse(
430 `${site.url!}/definitely/not/a/route`,
431 {
432 label: "404-page spa: worker-rendered 404 (non-navigation)",
433 headers: { accept: "text/html" },
434 expected: `status 404 with kit error marker "${KIT_ERROR_MARKER}"`,
435 check: (res) =>
436 res.status === 404 && res.body.includes(KIT_ERROR_MARKER),
437 },
438 );
439 expect(kitErrorBody).toContain("status:404");
440
441 yield* stack.destroy();
442 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
443 }).pipe(logLevel),
444 { timeout: 360_000 },
445 );
446
447 // ─────────────────────────────────────────────────────────────────────
448 // Fully-prerendered SvelteKit (the `adapter-static` equivalent):
449 // `export const prerender = true` in the root +layout renders every
450 // page to HTML at build time and deploys them as static assets.
451 // ─────────────────────────────────────────────────────────────────────
452
453 const staticFixtureDir = pathe.resolve(
454 import.meta.dirname,
455 "fixtures",
456 "sveltekit-static-app",
457 );
458
459 test.provider(
460 "SvelteKit fully-prerendered: pages serve as static assets; the server bundle is still uploaded as a worker (behavior pin)",
461 (stack) =>
462 Effect.gen(function* () {
463 const { accountId } = yield* yield* CloudflareEnvironment;
464
465 yield* stack.destroy();
466
467 const rootDir = yield* cloneFixture(staticFixtureDir, {
468 prefix: "alchemy-sveltekit-static-",
469 tempRoot,
470 entries: ["package.json", "src"],
471 });
472
473 const site = yield* stack.deploy(
474 Effect.gen(function* () {
475 return yield* Cloudflare.Website.SvelteKit(
476 "SvelteKitStaticSite",
477 {
478 rootDir,
479 workersDev: { enabled: true, previewsEnabled: true },
480 memo: { include: ["src/**", "package.json"] },
481 },
482 );
483 }),
484 );
485
486 expect(site.url).toBeDefined();
487
488 // ── BEHAVIOR PIN ─────────────────────────────────────────────────
489 // Even with every route prerendered, the resource TODAY still
490 // builds the kit server bundle and uploads it as a worker (the
491 // adapter always emits `_worker.js`; there is no assets-only fast
492 // path for fully-static builds). If these assertions start failing
493 // because the resource learned to skip the worker for fully-
494 // prerendered apps, update this pin to the new behavior.
495 expect(site.hash?.bundle).toBeDefined();
496 yield* expectWorkerExists(site.workerName, accountId);
497
498 // Every page serves its prerendered content.
499 yield* expectUrlContains(`${site.url!}/`, "sveltekit-static-home", {
500 timeout: "120 seconds",
501 label: "prerendered home",
502 });
503 yield* expectUrlContains(
504 `${site.url!}/about`,
505 "sveltekit-static-about",
506 {
507 timeout: "60 seconds",
508 label: "prerendered about",
509 },
510 );
511 yield* expectUrlContains(
512 `${site.url!}/docs`,
513 "sveltekit-static-docs",
514 {
515 timeout: "60 seconds",
516 label: "prerendered docs",
517 },
518 );
519
520 // Proof the pages come from the ASSETS layer, not a kit render:
521 // the assets router's `auto-trailing-slash` html_handling answers
522 // `/about/` with a 307 redirect to `/about`. Both kit's server
523 // (trailingSlash "never") and the worker shim's prerendered
524 // redirect use 308 — a 307 here fingerprints the assets layer.
525 yield* expectUrlRedirect(`${site.url!}/about/`, "/about", {
526 status: 307,
527 timeout: "60 seconds",
528 label: "assets-layer trailing-slash redirect",
529 });
530
531 yield* stack.destroy();
532 yield* waitForWorkerToBeDeleted(site.workerName, accountId);
533 }).pipe(logLevel),
534 { timeout: 360_000 },
535 );
536
537 class NamespaceStillExists extends Data.TaggedError(
538 "NamespaceStillExists",
539 ) {}
540
541 const waitForNamespaceToBeDeleted = Effect.fn(function* (
542 namespaceId: string,
543 accountId: string,
544 ) {
545 yield* kv.getNamespace({ accountId, namespaceId }).pipe(
546 Effect.flatMap(() => Effect.fail(new NamespaceStillExists())),
547 Effect.retry({
548 while: (e): e is NamespaceStillExists =>
549 e instanceof NamespaceStillExists,
550 schedule: Schedule.min([
551 Schedule.exponential(250),
552 Schedule.spaced("2 seconds"),
553 ]),
554 times: 10,
555 }),
556 Effect.catchTag("NamespaceNotFound", () => Effect.void),
557 );
558 });
559
560 class PageResponseMismatch extends Data.TaggedError(
561 "PageResponseMismatch",
562 )<{
563 url: string;
564 expected: string;
565 actual: string;
566 }> {
567 override get message() {
568 return `${this.url} :: expected ${this.expected} :: got ${this.actual}`;
569 }
570 }
571
572 interface PageResponse {
573 status: number;
574 body: string;
575 headers: Headers;
576 }
577
578 /**
579 * Fetch `url` (with optional extra request headers — e.g.
580 * `sec-fetch-mode: navigate` to exercise the assets layer's
581 * navigation-preference behavior) and assert `check` holds for the
582 * response. Retries through edge propagation with the same bounded
583 * schedule as `expectSpaShell`; returns the body for further (negative)
584 * assertions.
585 */
586 const expectPageResponse = (
587 url: string,
588 options: {
589 label: string;
590 headers?: Record<string, string>;
591 /** Human description of the expected response, for error messages. */
592 expected: string;
593 check: (res: PageResponse) => boolean;
594 },
595 ) =>
596 Effect.tryPromise({
597 try: async (signal) => {
598 const u = new URL(url);
599 u.searchParams.set("__alchemy_cb", String(Date.now()));
600 const res = await fetch(u, {
601 signal,
602 cache: "no-store",
603 headers: {
604 "cache-control": "no-cache",
605 accept: "*/*",
606 ...options.headers,
607 },
608 });
609 const body = await res.text();
610 return { status: res.status, body, headers: res.headers };
611 },
612 catch: (e) =>
613 new PageResponseMismatch({
614 url,
615 expected: options.expected,
616 actual: e instanceof Error ? e.message : String(e),
617 }),
618 }).pipe(
619 Effect.filterOrFail(
620 (res): boolean => options.check(res),
621 (res) =>
622 new PageResponseMismatch({
623 url,
624 expected: options.expected,
625 actual: `${res.status} ${res.body.slice(0, 240)}`,
626 }),
627 ),
628 Effect.map((res) => res.body),
629 Effect.retry({
630 // Same ~127s budget as `expectSpaShell` (matches the 120s
631 // first-request convention used by `expectUrlContains`).
632 schedule: Schedule.max([
633 Schedule.min([
634 Schedule.exponential("750 millis", 1.5),
635 Schedule.spaced("8 seconds"),
636 ]),
637 Schedule.recurs(20),
638 ]),
639 }),
640 Effect.tapError((error) =>
641 Effect.logError(`expectPageResponse(${options.label}) failed`, error),
642 ),
643 );
644
645 class SpaShellMismatch extends Data.TaggedError("SpaShellMismatch")<{
646 url: string;
647 actual: string;
648 }> {
649 override get message() {
650 return `${this.url} :: ${this.actual}`;
651 }
652 }
653
654 /**
655 * Assert `url` answers 200 with the SPA app-shell body (contains the
656 * fixture's shell marker). Retries through edge propagation with a
657 * bounded schedule; returns the body for further (negative) assertions.
658 */
659 const expectSpaShell = (url: string) =>
660 Effect.tryPromise({
661 try: async (signal) => {
662 const u = new URL(url);
663 u.searchParams.set("__alchemy_cb", String(Date.now()));
664 const res = await fetch(u, {
665 signal,
666 cache: "no-store",
667 headers: { "cache-control": "no-cache", accept: "text/html" },
668 });
669 const body = await res.text();
670 return { status: res.status, body };
671 },
672 catch: (e) =>
673 new SpaShellMismatch({
674 url,
675 actual: e instanceof Error ? e.message : String(e),
676 }),
677 }).pipe(
678 Effect.filterOrFail(
679 (res) => res.status === 200 && res.body.includes(SPA_SHELL_MARKER),
680 (res) =>
681 new SpaShellMismatch({
682 url,
683 actual: `${res.status} ${res.body.slice(0, 240)}`,
684 }),
685 ),
686 Effect.map((res) => res.body),
687 Effect.retry({
688 // ~127s budget — matches the 120s first-request convention used by
689 // `expectUrlContains`: a fully concurrent suite creates dozens of
690 // fresh workers.dev subdomains at once and propagation slows down.
691 schedule: Schedule.max([
692 Schedule.min([
693 Schedule.exponential("750 millis", 1.5),
694 Schedule.spaced("8 seconds"),
695 ]),
696 Schedule.recurs(20),
697 ]),
698 }),
699 );
700
701 /**
702 * POST an urlencoded form to a kit action URL (`/route?/action`) with an
703 * `origin` header matching the site (kit's CSRF check) and
704 * `accept: text/html` so the action result is server-rendered into the
705 * page. Retries until the route serves 200.
706 */
707 const postFormReady = (
708 url: string,
709 origin: string,
710 form: Record<string, string>,
711 ) =>
712 Effect.gen(function* () {
713 const client = yield* HttpClient.HttpClient;
714 return yield* client
715 .execute(
716 HttpClientRequest.post(url).pipe(
717 HttpClientRequest.setHeaders({ origin, accept: "text/html" }),
718 HttpClientRequest.bodyText(
719 new URLSearchParams(form).toString(),
720 "application/x-www-form-urlencoded",
721 ),
722 ),
723 )
724 .pipe(
725 Effect.flatMap((res) =>
726 res.status === 200
727 ? res.text
728 : Effect.fail(
729 new Error(`form action not ready: ${res.status}`),
730 ),
731 ),
732 Effect.retry({
733 schedule: Schedule.min([
734 Schedule.exponential("500 millis"),
735 Schedule.spaced("4 seconds"),
736 ]),
737 times: 15,
738 }),
739 );
740 });
741
742 /**
743 * PUT a text body to `url`, retrying until the route serves 200. Kit's
744 * CSRF protection 403s form-like content types (`text/plain` included)
745 * on non-GET requests unless the `origin` header matches the site, so
746 * the site origin is sent explicitly.
747 */
748 const putTextReady = (url: string, body: string) =>
749 Effect.gen(function* () {
750 const client = yield* HttpClient.HttpClient;
751 yield* client
752 .execute(
753 HttpClientRequest.put(url).pipe(
754 HttpClientRequest.setHeaders({ origin: new URL(url).origin }),
755 HttpClientRequest.bodyText(body, "text/plain"),
756 ),
757 )
758 .pipe(
759 Effect.flatMap((res) =>
760 res.status === 200
761 ? Effect.void
762 : Effect.fail(new Error(`PUT not ready: ${res.status}`)),
763 ),
764 Effect.retry({
765 schedule: Schedule.min([
766 Schedule.exponential("500 millis"),
767 Schedule.spaced("4 seconds"),
768 ]),
769 times: 15,
770 }),
771 );
772 });
773
774 const fetchJsonReady = <T>(url: string) =>
775 Effect.gen(function* () {
776 const client = yield* HttpClient.HttpClient;
777 return yield* client.get(url).pipe(
778 Effect.flatMap((res) =>
779 res.status === 200
780 ? Effect.flatMap(res.text, (body) =>
781 Effect.try({
782 try: () => JSON.parse(body) as T,
783 catch: () => new Error(`non-json body: ${body}`),
784 }),
785 )
786 : Effect.fail(new Error(`Worker not ready: ${res.status}`)),
787 ),
788 Effect.retry({
789 // Capped interval, ~90s total budget (workers.dev / DO propagation).
790 schedule: Schedule.min([
791 Schedule.exponential("500 millis"),
792 Schedule.spaced("2 seconds"),
793 ]),
794 times: 45,
795 }),
796 );
797 });
798 },
799);