1import * as Data from "effect/Data";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5
6/**
7 * HTTP assertion helpers tuned for Cloudflare's "deploy → propagate →
8 * serve" pipeline.
9 *
10 * Newly-deployed workers.dev URLs commonly:
11 * - Return Cloudflare's "There is nothing here yet" placeholder page
12 * while the workers.dev subdomain is enabling for the first time.
13 * - Serve a stale cached body for a few seconds after the version flip.
14 * - Briefly 522 / 530 while the edge fetches the new version.
15 *
16 * The helpers here retry through all of those failure modes with an
17 * exponential backoff capped by an explicit total `timeout`, while
18 * still surfacing a precise, actionable error if the budget is
19 * exhausted.
20 */
21
22export class HttpAssertionFailed extends Data.TaggedError(
23 "HttpAssertionFailed",
24)<{
25 url: string;
26 marker: string;
27 status: number;
28 bodyExcerpt: string;
29}> {}
30
31export class HttpFetchFailed extends Data.TaggedError("HttpFetchFailed")<{
32 url: string;
33 message: string;
34}> {}
35
36export class HttpMarkerPresent extends Data.TaggedError("HttpMarkerPresent")<{
37 url: string;
38 marker: string;
39 bodyExcerpt: string;
40}> {}
41
42export interface ExpectUrlContainsOptions {
43 /** Request headers, e.g. an explicit HTML Accept header for content negotiation. */
44 headers?: Record<string, string>;
45 /** Maximum total time to retry before failing. Default 90s. */
46 timeout?: Duration.Input;
47 /** Initial backoff between attempts. Default 750ms. */
48 initialBackoff?: Duration.Input;
49 /** Description used in error messages. */
50 label?: string;
51}
52
53const looksLikeCloudflarePlaceholder = (body: string) =>
54 // Cloudflare's "no worker / not propagated" landing page.
55 body.includes("There is nothing here yet") ||
56 // The blue 522 / 1xxx error page family.
57 /Error\s+\d{3,4}/i.test(body);
58
59/**
60 * Hard settlement bound for a single attempt, applied at the PROMISE level
61 * (`Promise.race`), not via fiber interruption: bun's fetch can wedge on a
62 * pooled connection the server closed after an error response — the
63 * request's promise neither settles nor honors its abort signal, and any
64 * Effect timeout that interrupts `tryPromise` then awaits that settlement
65 * forever. The race settles regardless and simply abandons the wedged
66 * promise. Generous on purpose: a cold dev server's first SSR/MDX compile
67 * can legitimately take tens of seconds.
68 */
69const ATTEMPT_SETTLEMENT_CAP_MS = 60_000;
70
71const fetchOnce = (
72 url: string,
73 marker: string,
74 headers?: Record<string, string>,
75) =>
76 Effect.tryPromise({
77 try: async (signal) => {
78 // Cache-busting query string defeats both edge caches and any
79 // intermediate proxy that ignores `cache-control: no-cache`.
80 const u = new URL(url);
81 u.searchParams.set("__alchemy_cb", String(Date.now()));
82 const attempt = async () => {
83 const res = await fetch(u, {
84 signal,
85 cache: "no-store",
86 headers: {
87 "cache-control": "no-cache",
88 pragma: "no-cache",
89 accept: "*/*",
90 // No keep-alive reuse: the wedge above starts with a reused
91 // connection the server already closed.
92 connection: "close",
93 ...headers,
94 },
95 });
96 return { res, body: await res.text() };
97 };
98 let capTimer: ReturnType<typeof setTimeout> | undefined;
99 const cap = new Promise<never>((_, reject) => {
100 capTimer = setTimeout(
101 () =>
102 reject(
103 new Error(
104 `attempt did not settle within ${ATTEMPT_SETTLEMENT_CAP_MS}ms — abandoned`,
105 ),
106 ),
107 ATTEMPT_SETTLEMENT_CAP_MS,
108 );
109 });
110 const { res, body } = await Promise.race([attempt(), cap]).finally(() =>
111 clearTimeout(capTimer),
112 );
113 if (
114 !res.ok ||
115 looksLikeCloudflarePlaceholder(body) ||
116 !body.includes(marker)
117 ) {
118 if (process.env.DEBUG_HTTP_ASSERT) {
119 const flat = body.replace(/\s+/g, " ");
120 const interesting =
121 flat.match(/(Error|Worker threw|exception)[^<]{0,140}/gi) ?? [];
122 console.error(
123 `[http-assert] ${res.status} ${url} :: ${interesting.length ? interesting.slice(0, 3).join(" | ") : flat.slice(0, 160)}`,
124 );
125 }
126 throw new HttpAssertionFailed({
127 url,
128 marker,
129 status: res.status,
130 bodyExcerpt: body.slice(0, 240),
131 });
132 }
133 return body;
134 },
135 catch: (e) =>
136 e instanceof HttpAssertionFailed
137 ? e
138 : new HttpFetchFailed({
139 url,
140 message: e instanceof Error ? e.message : String(e),
141 }),
142 });
143
144/**
145 * Fetch `url` and assert the response body contains `marker`. Retries
146 * the fetch on transient failures (network errors, 5xx, Cloudflare
147 * placeholders, missing marker) until the marker appears or the
148 * `timeout` elapses. Each attempt uses a fresh cache-busting query
149 * param and `cache: "no-store"` so we don't sit on a CDN-cached body.
150 *
151 * The hash assertions in the deploy tests prove the *intent* to
152 * publish; this helper proves the assets actually serve over HTTP.
153 */
154export const expectUrlContains = (
155 url: string,
156 marker: string,
157 options: ExpectUrlContainsOptions = {},
158) => {
159 const totalTimeout = Duration.fromInputUnsafe(
160 options.timeout ?? "90 seconds",
161 );
162 const initial = options.initialBackoff ?? "750 millis";
163 const label = options.label ?? "url";
164
165 return fetchOnce(url, marker, options.headers).pipe(
166 Effect.retry({
167 // Cap individual sleeps at 8s so very long timeouts still
168 // sample at a reasonable rate near the end of the budget.
169 schedule: Schedule.min([
170 Schedule.exponential(initial, 1.5),
171 Schedule.spaced("8 seconds"),
172 ]),
173 }),
174 // Bound the *total* retry budget. `Effect.retry` on its own would
175 // back off forever; the timeout guarantees the test fails loudly
176 // instead of stalling out the suite. We swap the default
177 // `TimeoutException` for a typed assertion error so the failure
178 // message says *what* we were waiting for, not just "timed out".
179 Effect.timeoutOrElse({
180 duration: totalTimeout,
181 orElse: () =>
182 Effect.fail(
183 new HttpAssertionFailed({
184 url,
185 marker,
186 status: 0,
187 bodyExcerpt: `[timed out after ${Duration.toMillis(totalTimeout)}ms waiting for marker "${marker}"]`,
188 }),
189 ),
190 }),
191 Effect.tapError((error) =>
192 Effect.logError(`expectUrlContains(${label}) failed`, error),
193 ),
194 );
195};
196
197/**
198 * Bounded readiness probe: the server behind `url` answers HTTP at all
199 * (any 2xx). Used by the local/dev suites, where there is no CDN to
200 * propagate through — only a dev server that needs a moment to listen.
201 */
202export const expectUrlOk = (url: string) =>
203 Effect.tryPromise({
204 try: async (signal) => {
205 const response = await fetch(url, { signal, cache: "no-store" });
206 await response.arrayBuffer().catch(() => {});
207 if (!response.ok) {
208 throw new Error(`${url} responded ${response.status}`);
209 }
210 return response.status;
211 },
212 catch: (error) => new Error(String(error)),
213 }).pipe(
214 Effect.retry({ schedule: Schedule.spaced("1 second"), times: 30 }),
215 Effect.timeout("60 seconds"),
216 );
217
218export class HttpResponseMismatch extends Data.TaggedError(
219 "HttpResponseMismatch",
220)<{
221 url: string;
222 expected: string;
223 actual: string;
224}> {}
225
226const fetchOnceResponse = (
227 url: string,
228 check: (res: Response) => HttpResponseMismatch | undefined,
229) =>
230 Effect.tryPromise({
231 try: async (signal) => {
232 const u = new URL(url);
233 u.searchParams.set("__alchemy_cb", String(Date.now()));
234 const res = await fetch(u, {
235 signal,
236 cache: "no-store",
237 redirect: "manual",
238 headers: {
239 "cache-control": "no-cache",
240 pragma: "no-cache",
241 accept: "*/*",
242 // No keep-alive reuse: bun's fetch can wedge on a pooled
243 // connection the server closed after an error response — the
244 // reused request's promise neither settles nor honors its abort
245 // signal, pinning any timeout that awaits its interruption.
246 connection: "close",
247 },
248 });
249 const mismatch = check(res);
250 if (mismatch) {
251 throw mismatch;
252 }
253 return res;
254 },
255 catch: (e) =>
256 e instanceof HttpResponseMismatch
257 ? e
258 : new HttpFetchFailed({
259 url,
260 message: e instanceof Error ? e.message : String(e),
261 }),
262 });
263
264const retryResponse = (
265 url: string,
266 expected: string,
267 effect: Effect.Effect<Response, HttpResponseMismatch | HttpFetchFailed>,
268 options: ExpectUrlContainsOptions,
269) => {
270 const totalTimeout = Duration.fromInputUnsafe(
271 options.timeout ?? "90 seconds",
272 );
273 const initial = options.initialBackoff ?? "750 millis";
274 const label = options.label ?? "url";
275 return effect.pipe(
276 Effect.retry({
277 schedule: Schedule.min([
278 Schedule.exponential(initial, 1.5),
279 Schedule.spaced("8 seconds"),
280 ]),
281 }),
282 Effect.timeoutOrElse({
283 duration: totalTimeout,
284 orElse: () =>
285 Effect.fail(
286 new HttpResponseMismatch({
287 url,
288 expected,
289 actual: `[timed out after ${Duration.toMillis(totalTimeout)}ms waiting for ${expected}]`,
290 }),
291 ),
292 }),
293 Effect.tapError((error) =>
294 Effect.logError(`expect response (${label}) failed`, error),
295 ),
296 );
297};
298
299/**
300 * Fetch `url` without following redirects and assert the response is a
301 * redirect to `location` (with `status`, default 301). Retries through
302 * deploy propagation like {@link expectUrlContains}.
303 */
304export const expectUrlRedirect = (
305 url: string,
306 location: string,
307 options: ExpectUrlContainsOptions & { status?: number } = {},
308) => {
309 const status = options.status ?? 301;
310 const expected = `${status} -> ${location}`;
311 return retryResponse(
312 url,
313 expected,
314 fetchOnceResponse(url, (res) => {
315 // Cloudflare appends the request's query string to the Location
316 // header, so compare pathnames rather than the raw value.
317 const actual = res.headers.get("location");
318 const actualPath = actual === null ? null : new URL(actual, url).pathname;
319 return res.status === status && actualPath === location
320 ? undefined
321 : new HttpResponseMismatch({
322 url,
323 expected,
324 actual: `${res.status} -> ${actual}`,
325 });
326 }),
327 options,
328 );
329};
330
331/**
332 * Fetch `url` and assert response header `header` equals `value`.
333 * Retries through deploy propagation like {@link expectUrlContains}.
334 */
335export const expectUrlHeader = (
336 url: string,
337 header: string,
338 value: string,
339 options: ExpectUrlContainsOptions = {},
340) => {
341 const expected = `${header}: ${value}`;
342 return retryResponse(
343 url,
344 expected,
345 fetchOnceResponse(url, (res) =>
346 res.headers.get(header) === value
347 ? undefined
348 : new HttpResponseMismatch({
349 url,
350 expected,
351 actual: `${res.status} ${header}: ${res.headers.get(header)}`,
352 }),
353 ),
354 options,
355 );
356};
357
358/**
359 * Fetch `url` WITHOUT following redirects and assert the immediate
360 * response status equals `expected` (and the body is not a Cloudflare
361 * placeholder page). Retries through deploy propagation like
362 * {@link expectUrlContains}. Use it to prove a URL serves (or redirects)
363 * *directly* rather than bouncing through a 3xx first.
364 */
365export const expectDirectStatus = (
366 url: string,
367 expected: number,
368 options: ExpectUrlContainsOptions = {},
369) =>
370 retryResponse(
371 url,
372 `direct ${expected}`,
373 Effect.tryPromise({
374 try: async (signal) => {
375 const u = new URL(url);
376 u.searchParams.set("__alchemy_cb", String(Date.now()));
377 const res = await fetch(u, {
378 signal,
379 cache: "no-store",
380 redirect: "manual",
381 headers: {
382 "cache-control": "no-cache",
383 pragma: "no-cache",
384 accept: "*/*",
385 },
386 });
387 const body = await res.text();
388 if (res.status !== expected || looksLikeCloudflarePlaceholder(body)) {
389 throw new HttpResponseMismatch({
390 url,
391 expected: `direct ${expected}`,
392 actual: `${res.status} ${body.slice(0, 240)}`,
393 });
394 }
395 return res;
396 },
397 catch: (e) =>
398 e instanceof HttpResponseMismatch
399 ? e
400 : new HttpFetchFailed({
401 url,
402 message: e instanceof Error ? e.message : String(e),
403 }),
404 }),
405 options,
406 );
407
408const fetchOnceAbsent = (url: string, marker: string) =>
409 Effect.tryPromise({
410 try: async (signal) => {
411 const u = new URL(url);
412 u.searchParams.set("__alchemy_cb", String(Date.now()));
413 const res = await fetch(u, {
414 signal,
415 cache: "no-store",
416 headers: {
417 "cache-control": "no-cache",
418 pragma: "no-cache",
419 accept: "*/*",
420 // No keep-alive reuse: bun's fetch can wedge on a pooled
421 // connection the server closed after an error response — the
422 // reused request's promise neither settles nor honors its abort
423 // signal, pinning any timeout that awaits its interruption.
424 connection: "close",
425 },
426 });
427 const body = await res.text();
428 if (body.includes(marker)) {
429 throw new HttpMarkerPresent({
430 url,
431 marker,
432 bodyExcerpt: body.slice(0, 240),
433 });
434 }
435 return body;
436 },
437 catch: (e) =>
438 e instanceof HttpMarkerPresent
439 ? e
440 : new HttpFetchFailed({
441 url,
442 message: e instanceof Error ? e.message : String(e),
443 }),
444 });
445
446/**
447 * Fetch `url` and assert the response body does *not* contain `marker`.
448 * Retries while the marker is still present so a briefly-overbroad route
449 * fails loudly instead of slipping through on the first fetch.
450 */
451export const expectUrlAbsent = (
452 url: string,
453 marker: string,
454 options: ExpectUrlContainsOptions = {},
455) => {
456 const totalTimeout = Duration.fromInputUnsafe(
457 options.timeout ?? "90 seconds",
458 );
459 const initial = options.initialBackoff ?? "750 millis";
460 const label = options.label ?? "url";
461
462 return fetchOnceAbsent(url, marker).pipe(
463 Effect.retry({
464 schedule: Schedule.min([
465 Schedule.exponential(initial, 1.5),
466 Schedule.spaced("8 seconds"),
467 ]),
468 }),
469 Effect.timeoutOrElse({
470 duration: totalTimeout,
471 orElse: () =>
472 Effect.fail(
473 new HttpMarkerPresent({
474 url,
475 marker,
476 bodyExcerpt: `[timed out after ${Duration.toMillis(totalTimeout)}ms waiting for marker "${marker}" to disappear]`,
477 }),
478 ),
479 }),
480 Effect.tapError((error) =>
481 Effect.logError(`expectUrlAbsent(${label}) failed`, error),
482 ),
483 );
484};