1import * as Cloudflare from "@/Cloudflare";
2import * as Test from "@/Test/Alchemy";
3import { expect } from "alchemy-test";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import * as HttpClient from "effect/http/HttpClient";
8import * as HttpClientRequest from "effect/http/HttpClientRequest";
9import Stack from "./fixtures/stack.ts";
10
11const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
12 providers: Cloudflare.providers(),
13});
14
15class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
16 status: number;
17 body: string;
18}> {}
19
20/**
21 * 1x1 red PNG — a known-good minimal image that Cloudflare Images accepts and
22 * reports as `image/png`, 1x1. The test uploads this to each worker, which
23 * forwards the request stream straight into `images.info()`.
24 */
25const TINY_PNG = new Uint8Array(
26 Buffer.from(
27 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
28 "base64",
29 ),
30);
31
32// Cloudflare's edge takes a few seconds to start serving a fresh workers.dev
33// URL — initial requests can return Cloudflare's "There is nothing here yet"
34// 404 page or, while the version is still propagating to the subdomain, the
35// blue "Error 1104 / Script not found" page (served with a 5xx status). Both
36// are transient propagation states, so retry through them until the worker
37// answers 200 (and surface its body if it doesn't, so a real failure isn't
38// hidden by the retry loop).
39const looksLikeCloudflarePlaceholder = (body: string) =>
40 body.includes("There is nothing here yet") ||
41 // The "Error 1104 / Script not found" page renders the word "Error" and
42 // the code in separate HTML tags, so match the page's own markers rather
43 // than a contiguous "Error NNNN" string.
44 body.includes("Script not found") ||
45 body.includes("cf-error-code") ||
46 /Error\s+\d{3,4}/i.test(body);
47
48const postImage = (url: string) =>
49 HttpClient.execute(
50 HttpClientRequest.post(url).pipe(
51 HttpClientRequest.bodyUint8Array(TINY_PNG),
52 ),
53 ).pipe(
54 Effect.flatMap((res) =>
55 res.status === 200
56 ? res.json
57 : res.text.pipe(
58 Effect.flatMap((body) =>
59 Effect.fail(new WorkerNotReady({ status: res.status, body })),
60 ),
61 ),
62 ),
63 Effect.retry({
64 while: (e): e is WorkerNotReady =>
65 e instanceof WorkerNotReady &&
66 ((e.status >= 400 && e.status < 500) ||
67 looksLikeCloudflarePlaceholder(e.body)),
68 // Cap each backoff at 5s (otherwise the exponential blows past a minute
69 // per sleep and looks like a hang) and stop after 30 attempts.
70 schedule: Schedule.max([
71 Schedule.min([
72 Schedule.exponential("500 millis"),
73 Schedule.spaced("5 seconds"),
74 ]),
75 Schedule.recurs(30),
76 ]),
77 }),
78 );
79
80const stack = beforeAll(deploy(Stack));
81afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
82
83test(
84 "async worker reads image info via env Images binding",
85 Effect.gen(function* () {
86 const { asyncWorkerUrl } = yield* stack;
87 const info = yield* postImage(asyncWorkerUrl);
88 expect(info).toMatchObject({
89 mode: "async",
90 format: "image/png",
91 width: 1,
92 height: 1,
93 });
94 }),
95 {
96 tags: [
97 "provider:cloudflare",
98 "provider:cloudflare:images",
99 "provider:cloudflare:worker",
100 "live",
101 ],
102 timeout: 240_000,
103 },
104);
105
106test(
107 "effect worker reads image info via yield* Images",
108 Effect.gen(function* () {
109 const { effectWorkerUrl } = yield* stack;
110 const info = yield* postImage(effectWorkerUrl);
111 expect(info).toMatchObject({
112 mode: "effect",
113 format: "image/png",
114 width: 1,
115 height: 1,
116 });
117 }),
118 {
119 tags: [
120 "provider:cloudflare",
121 "provider:cloudflare:images",
122 "provider:cloudflare:worker",
123 "live",
124 ],
125 timeout: 240_000,
126 },
127);