t3-code-android-nightly/.repos/alchemy-effect/packages/alchemy/test/Cloudflare/Website/SvelteKit.test.ts
Julius Marminge 6f9cea00ae
chore(refs): sync Effect and Alchemy references to 4.0.1 and beta.80 (#16170)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:22:30 -07:00

799 lines
31 KiB
TypeScript

import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
import * as Cloudflare from "@/Cloudflare/index.ts";
import * as Test from "@/Test/Alchemy";
import * as kv from "@distilled.cloud/cloudflare/kv";
import { describe, expect } from "alchemy-test";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import { MinimumLogLevel } from "effect/References";
import * as Schedule from "effect/Schedule";
import * as Stream from "effect/Stream";
import * as HttpClient from "effect/http/HttpClient";
import * as HttpClientRequest from "effect/http/HttpClientRequest";
import * as pathe from "pathe";
import { cloneFixture } from "../Utils/Fixture.ts";
import { expectUrlContains, expectUrlRedirect } from "../Utils/Http.ts";
import {
expectWorkerExists,
waitForWorkerToBeDeleted,
} from "../Utils/Worker.ts";
const { test } = Test.make({ providers: Cloudflare.providers() });
const logLevel = Effect.provideService(
MinimumLogLevel,
process.env.DEBUG ? "Debug" : "Info",
);
const fixtureDir = pathe.resolve(
import.meta.dirname,
"fixtures",
"sveltekit-app",
);
// Keep the temp clone under the alchemy package (same convention as the
// Vite tests) so the project root stays representable relative to cwd.
const tempRoot = pathe.resolve(import.meta.dirname, "../../../.tmp");
const svelteKitProps = (rootDir: string) => ({
rootDir,
workersDev: { enabled: true, previewsEnabled: true },
memo: { include: ["src/**", "static/**", "package.json"] },
});
// Concurrent like the other Website suites: the SvelteKit source build
// runs in a child process with cwd = project root (core/BuildChild.ts),
// so nothing here mutates process-global state.
describe.concurrent(
"SvelteKit",
{
tags: [
"provider:cloudflare",
"provider:cloudflare:website",
"provider:cloudflare:worker",
"live",
],
},
() => {
test.provider(
"SvelteKit: deploys SSR + bindings + static assets and memoizes unchanged rebuilds",
(stack) =>
Effect.gen(function* () {
const { accountId } = yield* yield* CloudflareEnvironment;
yield* stack.destroy();
const rootDir = yield* cloneFixture(fixtureDir, {
prefix: "alchemy-sveltekit-",
tempRoot,
entries: ["package.json", "src", "static"],
});
const bindingMarker = "sveltekit-binding-marker";
const deploy = () =>
stack.deploy(
Effect.gen(function* () {
// A REAL KV namespace bound into `platform.env.SITE_KV` — the
// /api/kv routes round-trip it server-side.
const siteKv = yield* Cloudflare.KV.Namespace("SiteKV");
const site = yield* Cloudflare.Website.SvelteKit(
"SvelteKitSite",
{
...svelteKitProps(rootDir),
env: {
TEST_BINDING: bindingMarker,
SITE_KV: siteKv,
},
},
);
return { site, siteKv };
}),
);
const { site: site1, siteKv } = yield* deploy();
expect(site1.url).toBeDefined();
expect(site1.hash?.input).toBeDefined();
yield* expectWorkerExists(site1.workerName, accountId);
// SSR route: the server `load` reads `platform.env.TEST_BINDING`.
yield* expectUrlContains(
`${site1.url!}/`,
`binding:${bindingMarker}`,
{
timeout: "120 seconds",
label: "SSR home with platform.env binding",
},
);
// API endpoint: node:crypto under nodejs_compat + platform.env.
const hello = yield* fetchJsonReady<{
uuid: string;
binding: string;
}>(`${site1.url!}/api/hello`);
expect(hello.binding).toBe(bindingMarker);
expect(hello.uuid).toMatch(/^[0-9a-f-]{36}$/);
// Static asset from `static/`.
yield* expectUrlContains(`${site1.url!}/robots.txt`, "User-agent", {
timeout: "60 seconds",
label: "static asset",
});
// Prerendered page served from assets.
yield* expectUrlContains(
`${site1.url!}/prerendered`,
"this-page-is-prerendered",
{
timeout: "60 seconds",
label: "prerendered page",
},
);
// ── form action: the first non-GET against an alchemy-deployed
// framework worker. The urlencoded POST must route through the asset
// layer to the worker, run the `greet` action server-side, and
// observe `platform.env` ─────────────────────────────────────────────
const formBody = yield* postFormReady(
`${site1.url!}/form?/greet`,
new URL(site1.url!).origin,
{ name: "alchemy" },
);
expect(formBody).toContain("result:hello alchemy");
expect(formBody).toContain(`form-binding:${bindingMarker}`);
// ── real KV binding: PUT writes through `platform.env.SITE_KV`,
// GET reads it back, and the cloud API sees the same value ──────────
expect(siteKv.namespaceId).toBeDefined();
const kvKey = "greeting";
const kvValue = `sveltekit-kv-${bindingMarker}`;
yield* putTextReady(`${site1.url!}/api/kv?key=${kvKey}`, kvValue);
const kvRead = yield* fetchJsonReady<{ value: string | null }>(
`${site1.url!}/api/kv?key=${kvKey}`,
).pipe(
Effect.filterOrFail(
(r) => r.value === kvValue,
(r) =>
new Error(`kv value not visible yet: ${JSON.stringify(r)}`),
),
Effect.retry({
schedule: Schedule.exponential("1 second", 1.5),
times: 8,
}),
);
expect(kvRead.value).toBe(kvValue);
// Out-of-band: the worker's write landed in the REAL namespace.
const cloudValue = yield* kv
.getNamespaceValue({
accountId,
namespaceId: siteKv.namespaceId,
keyName: kvKey,
})
.pipe(
Effect.flatMap((res) =>
Effect.tryPromise(() =>
new Response(
Stream.toReadableStream(res.body) as BodyInit,
).text(),
),
),
Effect.retry({
schedule: Schedule.exponential("1 second", 1.5),
times: 5,
}),
);
expect(cloudValue).toBe(kvValue);
// ── deploy 2: no changes ⇒ the rebuild-free input hash matches and
// the deploy short-circuits without building ─────────────────────────
const { site: site2 } = yield* deploy();
expect(site2.hash?.input).toBeDefined();
expect(site2.hash?.input).toEqual(site1.hash?.input);
expect(site2.url).toBe(site1.url);
// ── deploy 3: edit a route ⇒ the input hash busts and the rebuilt
// page serves the new content ────────────────────────────────────────
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const pagePath = path.join(rootDir, "src", "routes", "+page.svelte");
const editedMarker = "sveltekit-edited-marker";
const page = yield* fs.readFileString(pagePath);
yield* fs.writeFileString(
pagePath,
page.replace(
"SvelteKit SSR home",
`SvelteKit SSR home ${editedMarker}`,
),
);
const { site: site3 } = yield* deploy();
expect(site3.hash?.input).toBeDefined();
expect(site3.hash?.input).not.toEqual(site1.hash?.input);
yield* expectUrlContains(`${site3.url!}/`, editedMarker, {
timeout: "120 seconds",
label: "edited SSR home page",
});
yield* stack.destroy();
yield* waitForWorkerToBeDeleted(site1.workerName, accountId);
// Destroy must also delete the bound KV namespace from the cloud.
yield* waitForNamespaceToBeDeleted(siteKv.namespaceId, accountId);
}).pipe(logLevel),
{ tags: ["provider:cloudflare:kv"], timeout: 360_000 },
);
// ─────────────────────────────────────────────────────────────────────
// SPA mode: `assets.notFoundHandling: "single-page-application"`
//
// Pages are `ssr = false`, so deep links and unknown routes must serve
// the generated app-shell `index.html` (identified by the fixture's
// shell marker), while `+server.ts` endpoints still execute server-side
// in the worker with access to `platform.env`.
// ─────────────────────────────────────────────────────────────────────
const spaFixtureDir = pathe.resolve(
import.meta.dirname,
"fixtures",
"sveltekit-spa-app",
);
const SPA_SHELL_MARKER = "sveltekit-spa-shell";
test.provider(
"SvelteKit SPA: deep links and unknown routes serve the app shell; server endpoints still run in the worker",
(stack) =>
Effect.gen(function* () {
const { accountId } = yield* yield* CloudflareEnvironment;
yield* stack.destroy();
const rootDir = yield* cloneFixture(spaFixtureDir, {
prefix: "alchemy-sveltekit-spa-",
tempRoot,
entries: ["package.json", "src"],
});
const bindingMarker = "sveltekit-spa-binding-marker";
const site = yield* stack.deploy(
Effect.gen(function* () {
return yield* Cloudflare.Website.SvelteKit("SvelteKitSpaSite", {
rootDir,
workersDev: { enabled: true, previewsEnabled: true },
memo: { include: ["src/**", "package.json"] },
// The single platform-native knob: `assets.notFoundHandling`
// drives both the build-time app-shell fallback generation
// and the assets layer that serves it. This deploy pins that
// coupling — without it, unknown routes 404 with an empty
// body.
assets: { notFoundHandling: "single-page-application" },
env: {
TEST_BINDING: bindingMarker,
},
});
}),
);
expect(site.url).toBeDefined();
// SPA mode still ships a worker: the kit server bundle serves the
// `+server.ts` endpoints.
expect(site.hash?.bundle).toBeDefined();
yield* expectWorkerExists(site.workerName, accountId);
// (a) A deep link to a CLIENT route serves the app shell — the
// shell marker is present and the widget markup (which only ever
// renders in the browser) is absent.
const widgetsBody = yield* expectSpaShell(`${site.url!}/widgets`);
expect(widgetsBody).toContain(SPA_SHELL_MARKER);
expect(widgetsBody).not.toContain("sveltekit-spa-widgets");
expect(widgetsBody).not.toContain("sprocket");
// (b) A route that doesn't exist at all serves the shell too — the
// client router owns 404 handling in SPA mode.
const unknownBody = yield* expectSpaShell(
`${site.url!}/definitely/not/a/route`,
);
expect(unknownBody).toContain(SPA_SHELL_MARKER);
// (c) The server endpoint executes in the worker with the binding.
const widgets = yield* fetchJsonReady<{
server: boolean;
message: string | null;
widgets: Array<{ id: string; name: string }>;
}>(`${site.url!}/api/widgets`);
expect(widgets.server).toBe(true);
expect(widgets.message).toBe(bindingMarker);
expect(widgets.widgets.map((w) => w.name)).toEqual([
"sprocket",
"flange",
"grommet",
]);
yield* stack.destroy();
yield* waitForWorkerToBeDeleted(site.workerName, accountId);
}).pipe(logLevel),
{ timeout: 360_000 },
);
// ─────────────────────────────────────────────────────────────────────
// 404-page mode: `assets.notFoundHandling: "404-page"`
//
// The build writes a `404.html` fallback asset that always renders the
// app shell (so kit's own error page shows), driven by the same
// platform-native knob that configures the assets layer serving it.
//
// Assets `404-page` semantics with a worker present: under the
// `assets_navigation_prefers_asset_serving` compat flag (default-on for
// compatibility dates >= 2025-04-01; the resource's default date is
// later), a navigation-shaped request (`Sec-Fetch-Mode: navigate`) that
// matches no asset is answered by the assets layer directly with the
// `404.html` body and status 404 — the worker is never invoked. Requests
// WITHOUT the navigate header still invoke the worker, where kit renders
// its own error page (the shim never defers in 404-page mode —
// upstream-parity, see WorkerShim.ts).
//
// ─────────────────────────────────────────────────────────────────────
const notFoundFixtureDir = pathe.resolve(
import.meta.dirname,
"fixtures",
"sveltekit-404-app",
);
/** app.html marker — present in every response rendered through app.html. */
const SHELL_404_MARKER = "sveltekit-404-shell";
/** Root `+error.svelte` marker — present only when KIT renders the 404. */
const KIT_ERROR_MARKER = "sveltekit-404-kit-error-page";
/** Root page marker — present only when the home page renders. */
const HOME_404_MARKER = "sveltekit-404-home";
test.provider(
"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",
(stack) =>
Effect.gen(function* () {
const { accountId } = yield* yield* CloudflareEnvironment;
yield* stack.destroy();
const rootDir = yield* cloneFixture(notFoundFixtureDir, {
prefix: "alchemy-sveltekit-404-spa-",
tempRoot,
entries: ["package.json", "src"],
});
const bindingMarker = "sveltekit-404-spa-binding-marker";
const site = yield* stack.deploy(
Effect.gen(function* () {
return yield* Cloudflare.Website.SvelteKit(
"SvelteKit404SpaSite",
{
rootDir,
workersDev: { enabled: true, previewsEnabled: true },
memo: { include: ["src/**", "package.json"] },
// `404-page` writes `404.html` — the generated page always
// renders the app shell now, so kit's own error page shows
// on unmatched navigations.
assets: { notFoundHandling: "404-page" },
env: {
TEST_BINDING: bindingMarker,
},
},
);
}),
);
expect(site.url).toBeDefined();
expect(site.hash?.bundle).toBeDefined();
yield* expectWorkerExists(site.workerName, accountId);
// (a) Matched route SSRs normally: a plain (non-navigation) fetch
// reaches the worker and the server `load` observes platform.env.
const homeBody = yield* expectUrlContains(
`${site.url!}/`,
`binding:${bindingMarker}`,
{
timeout: "120 seconds",
label: "404-page spa: SSR home",
},
);
expect(homeBody).toContain(HOME_404_MARKER);
// (b) An unmatched route on a navigation-shaped request serves the
// generated app-shell 404.html from the assets layer with status
// 404 — no kit error-page markup (the worker never ran) and no
// page markup (the shell render has no page components).
const fallbackBody = yield* expectPageResponse(
`${site.url!}/definitely/not/a/route`,
{
label: "404-page spa: assets fallback (navigate)",
headers: { accept: "text/html", "sec-fetch-mode": "navigate" },
expected: `status 404 with app-shell marker "${SHELL_404_MARKER}" and no "${KIT_ERROR_MARKER}"`,
check: (res) =>
res.status === 404 &&
res.body.includes(SHELL_404_MARKER) &&
!res.body.includes(KIT_ERROR_MARKER),
},
);
expect(fallbackBody).not.toContain(HOME_404_MARKER);
// (c) The same unmatched route WITHOUT `Sec-Fetch-Mode: navigate`
// invokes the worker, and kit renders its own error page — the
// shim never defers in 404-page mode (upstream parity).
const kitErrorBody = yield* expectPageResponse(
`${site.url!}/definitely/not/a/route`,
{
label: "404-page spa: worker-rendered 404 (non-navigation)",
headers: { accept: "text/html" },
expected: `status 404 with kit error marker "${KIT_ERROR_MARKER}"`,
check: (res) =>
res.status === 404 && res.body.includes(KIT_ERROR_MARKER),
},
);
expect(kitErrorBody).toContain("status:404");
yield* stack.destroy();
yield* waitForWorkerToBeDeleted(site.workerName, accountId);
}).pipe(logLevel),
{ timeout: 360_000 },
);
// ─────────────────────────────────────────────────────────────────────
// Fully-prerendered SvelteKit (the `adapter-static` equivalent):
// `export const prerender = true` in the root +layout renders every
// page to HTML at build time and deploys them as static assets.
// ─────────────────────────────────────────────────────────────────────
const staticFixtureDir = pathe.resolve(
import.meta.dirname,
"fixtures",
"sveltekit-static-app",
);
test.provider(
"SvelteKit fully-prerendered: pages serve as static assets; the server bundle is still uploaded as a worker (behavior pin)",
(stack) =>
Effect.gen(function* () {
const { accountId } = yield* yield* CloudflareEnvironment;
yield* stack.destroy();
const rootDir = yield* cloneFixture(staticFixtureDir, {
prefix: "alchemy-sveltekit-static-",
tempRoot,
entries: ["package.json", "src"],
});
const site = yield* stack.deploy(
Effect.gen(function* () {
return yield* Cloudflare.Website.SvelteKit(
"SvelteKitStaticSite",
{
rootDir,
workersDev: { enabled: true, previewsEnabled: true },
memo: { include: ["src/**", "package.json"] },
},
);
}),
);
expect(site.url).toBeDefined();
// ── BEHAVIOR PIN ─────────────────────────────────────────────────
// Even with every route prerendered, the resource TODAY still
// builds the kit server bundle and uploads it as a worker (the
// adapter always emits `_worker.js`; there is no assets-only fast
// path for fully-static builds). If these assertions start failing
// because the resource learned to skip the worker for fully-
// prerendered apps, update this pin to the new behavior.
expect(site.hash?.bundle).toBeDefined();
yield* expectWorkerExists(site.workerName, accountId);
// Every page serves its prerendered content.
yield* expectUrlContains(`${site.url!}/`, "sveltekit-static-home", {
timeout: "120 seconds",
label: "prerendered home",
});
yield* expectUrlContains(
`${site.url!}/about`,
"sveltekit-static-about",
{
timeout: "60 seconds",
label: "prerendered about",
},
);
yield* expectUrlContains(
`${site.url!}/docs`,
"sveltekit-static-docs",
{
timeout: "60 seconds",
label: "prerendered docs",
},
);
// Proof the pages come from the ASSETS layer, not a kit render:
// the assets router's `auto-trailing-slash` html_handling answers
// `/about/` with a 307 redirect to `/about`. Both kit's server
// (trailingSlash "never") and the worker shim's prerendered
// redirect use 308 — a 307 here fingerprints the assets layer.
yield* expectUrlRedirect(`${site.url!}/about/`, "/about", {
status: 307,
timeout: "60 seconds",
label: "assets-layer trailing-slash redirect",
});
yield* stack.destroy();
yield* waitForWorkerToBeDeleted(site.workerName, accountId);
}).pipe(logLevel),
{ timeout: 360_000 },
);
class NamespaceStillExists extends Data.TaggedError(
"NamespaceStillExists",
) {}
const waitForNamespaceToBeDeleted = Effect.fn(function* (
namespaceId: string,
accountId: string,
) {
yield* kv.getNamespace({ accountId, namespaceId }).pipe(
Effect.flatMap(() => Effect.fail(new NamespaceStillExists())),
Effect.retry({
while: (e): e is NamespaceStillExists =>
e instanceof NamespaceStillExists,
schedule: Schedule.min([
Schedule.exponential(250),
Schedule.spaced("2 seconds"),
]),
times: 10,
}),
Effect.catchTag("NamespaceNotFound", () => Effect.void),
);
});
class PageResponseMismatch extends Data.TaggedError(
"PageResponseMismatch",
)<{
url: string;
expected: string;
actual: string;
}> {
override get message() {
return `${this.url} :: expected ${this.expected} :: got ${this.actual}`;
}
}
interface PageResponse {
status: number;
body: string;
headers: Headers;
}
/**
* Fetch `url` (with optional extra request headers — e.g.
* `sec-fetch-mode: navigate` to exercise the assets layer's
* navigation-preference behavior) and assert `check` holds for the
* response. Retries through edge propagation with the same bounded
* schedule as `expectSpaShell`; returns the body for further (negative)
* assertions.
*/
const expectPageResponse = (
url: string,
options: {
label: string;
headers?: Record<string, string>;
/** Human description of the expected response, for error messages. */
expected: string;
check: (res: PageResponse) => boolean;
},
) =>
Effect.tryPromise({
try: async (signal) => {
const u = new URL(url);
u.searchParams.set("__alchemy_cb", String(Date.now()));
const res = await fetch(u, {
signal,
cache: "no-store",
headers: {
"cache-control": "no-cache",
accept: "*/*",
...options.headers,
},
});
const body = await res.text();
return { status: res.status, body, headers: res.headers };
},
catch: (e) =>
new PageResponseMismatch({
url,
expected: options.expected,
actual: e instanceof Error ? e.message : String(e),
}),
}).pipe(
Effect.filterOrFail(
(res): boolean => options.check(res),
(res) =>
new PageResponseMismatch({
url,
expected: options.expected,
actual: `${res.status} ${res.body.slice(0, 240)}`,
}),
),
Effect.map((res) => res.body),
Effect.retry({
// Same ~127s budget as `expectSpaShell` (matches the 120s
// first-request convention used by `expectUrlContains`).
schedule: Schedule.max([
Schedule.min([
Schedule.exponential("750 millis", 1.5),
Schedule.spaced("8 seconds"),
]),
Schedule.recurs(20),
]),
}),
Effect.tapError((error) =>
Effect.logError(`expectPageResponse(${options.label}) failed`, error),
),
);
class SpaShellMismatch extends Data.TaggedError("SpaShellMismatch")<{
url: string;
actual: string;
}> {
override get message() {
return `${this.url} :: ${this.actual}`;
}
}
/**
* Assert `url` answers 200 with the SPA app-shell body (contains the
* fixture's shell marker). Retries through edge propagation with a
* bounded schedule; returns the body for further (negative) assertions.
*/
const expectSpaShell = (url: string) =>
Effect.tryPromise({
try: async (signal) => {
const u = new URL(url);
u.searchParams.set("__alchemy_cb", String(Date.now()));
const res = await fetch(u, {
signal,
cache: "no-store",
headers: { "cache-control": "no-cache", accept: "text/html" },
});
const body = await res.text();
return { status: res.status, body };
},
catch: (e) =>
new SpaShellMismatch({
url,
actual: e instanceof Error ? e.message : String(e),
}),
}).pipe(
Effect.filterOrFail(
(res) => res.status === 200 && res.body.includes(SPA_SHELL_MARKER),
(res) =>
new SpaShellMismatch({
url,
actual: `${res.status} ${res.body.slice(0, 240)}`,
}),
),
Effect.map((res) => res.body),
Effect.retry({
// ~127s budget — matches the 120s first-request convention used by
// `expectUrlContains`: a fully concurrent suite creates dozens of
// fresh workers.dev subdomains at once and propagation slows down.
schedule: Schedule.max([
Schedule.min([
Schedule.exponential("750 millis", 1.5),
Schedule.spaced("8 seconds"),
]),
Schedule.recurs(20),
]),
}),
);
/**
* POST an urlencoded form to a kit action URL (`/route?/action`) with an
* `origin` header matching the site (kit's CSRF check) and
* `accept: text/html` so the action result is server-rendered into the
* page. Retries until the route serves 200.
*/
const postFormReady = (
url: string,
origin: string,
form: Record<string, string>,
) =>
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
return yield* client
.execute(
HttpClientRequest.post(url).pipe(
HttpClientRequest.setHeaders({ origin, accept: "text/html" }),
HttpClientRequest.bodyText(
new URLSearchParams(form).toString(),
"application/x-www-form-urlencoded",
),
),
)
.pipe(
Effect.flatMap((res) =>
res.status === 200
? res.text
: Effect.fail(
new Error(`form action not ready: ${res.status}`),
),
),
Effect.retry({
schedule: Schedule.min([
Schedule.exponential("500 millis"),
Schedule.spaced("4 seconds"),
]),
times: 15,
}),
);
});
/**
* PUT a text body to `url`, retrying until the route serves 200. Kit's
* CSRF protection 403s form-like content types (`text/plain` included)
* on non-GET requests unless the `origin` header matches the site, so
* the site origin is sent explicitly.
*/
const putTextReady = (url: string, body: string) =>
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
yield* client
.execute(
HttpClientRequest.put(url).pipe(
HttpClientRequest.setHeaders({ origin: new URL(url).origin }),
HttpClientRequest.bodyText(body, "text/plain"),
),
)
.pipe(
Effect.flatMap((res) =>
res.status === 200
? Effect.void
: Effect.fail(new Error(`PUT not ready: ${res.status}`)),
),
Effect.retry({
schedule: Schedule.min([
Schedule.exponential("500 millis"),
Schedule.spaced("4 seconds"),
]),
times: 15,
}),
);
});
const fetchJsonReady = <T>(url: string) =>
Effect.gen(function* () {
const client = yield* HttpClient.HttpClient;
return yield* client.get(url).pipe(
Effect.flatMap((res) =>
res.status === 200
? Effect.flatMap(res.text, (body) =>
Effect.try({
try: () => JSON.parse(body) as T,
catch: () => new Error(`non-json body: ${body}`),
}),
)
: Effect.fail(new Error(`Worker not ready: ${res.status}`)),
),
Effect.retry({
// Capped interval, ~90s total budget (workers.dev / DO propagation).
schedule: Schedule.min([
Schedule.exponential("500 millis"),
Schedule.spaced("2 seconds"),
]),
times: 45,
}),
);
});
},
);