1import * as Cloudflare from "@/Cloudflare";
2import * as Test from "@/Test/Alchemy";
3import { expect } from "alchemy-test";
4import * as Effect from "effect/Effect";
5import { MinimumLogLevel } from "effect/References";
6import * as Schedule from "effect/Schedule";
7import * as HttpClient from "effect/http/HttpClient";
8import * as HttpClientRequest from "effect/http/HttpClientRequest";
9import type { HttpClientResponse } from "effect/http/HttpClientResponse";
10import * as HttpApiClient from "effect/http-api/HttpApiClient";
11import { TaskApi } from "./fixtures/http-api/api.ts";
12import Stack from "./fixtures/http-api/stack.ts";
13
14const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
15 providers: Cloudflare.providers(),
16});
17
18const logLevel = Effect.provideService(
19 MinimumLogLevel,
20 process.env.DEBUG ? "Debug" : "Info",
21);
22
23const testTimeout = 60_000;
24const burstTimeout = 90_000;
25const requestTimeout = "5 seconds";
26// A fresh Cloudflare deploy is eventually consistent and NOT atomic across
27// edge PoPs: the script, the workers.dev route, and each binding (R2 / D1 /
28// DO namespace + migration) propagate independently. Until they converge,
29// requests landing on a cold PoP return a `404` (route not resolvable) or a
30// `500` (script up, binding not ready). New DO namespaces / D1 databases are
31// the slowest, so the readiness window comfortably exceeds 15s in the tail.
32// Retry on a steady 1.5s cadence for ~60s so every first-touch request rides
33// out the convergence window regardless of which PoP it hits. Under a
34// full-suite run (dozens of concurrent deploys) fresh DO namespaces have
35// been observed to serve 500s for well over 30s before converging.
36const readinessRetry = {
37 schedule: Schedule.spaced("1500 millis"),
38 times: 40,
39} as const;
40
41const makeClient = (url: string) =>
42 HttpApiClient.make(TaskApi, { baseUrl: url });
43
44// The raw `HttpClient` (used for transport-level CORS checks) does not fail on
45// a non-2xx status, so `Effect.retry` won't fire on the freshly-deployed edge
46// 404/500 window. Explicitly `Effect.fail` non-2xx responses to force the
47// retry (unlike the typed `HttpApiClient`, which already fails on them).
48const requestUntilReady = (
49 effect: Effect.Effect<HttpClientResponse, unknown, never>,
50) =>
51 effect.pipe(
52 Effect.timeout(requestTimeout),
53 Effect.flatMap(
54 Effect.fn(function* (res) {
55 return res.status >= 200 && res.status < 300
56 ? res
57 : yield* Effect.fail(
58 new Error(`Worker not ready: ${res.status} ${yield* res.text}`),
59 );
60 }),
61 ),
62 Effect.retry(readinessRetry),
63 );
64
65// Gate the deploy on the worker having propagated to the edge: hit both the
66// R2-backed (`createTask`) and DO-backed (`createTaskDO`) paths once, retrying
67// through the freshly-deployed 404 window, then give it extra time to settle
68// across edge PoPs. Individual tests can then call the API directly without
69// each re-implementing a per-request readiness retry.
70const stack = beforeAll(
71 deploy(Stack).pipe(
72 Effect.tap(({ url }) =>
73 Effect.gen(function* () {
74 const client = yield* makeClient(url);
75 yield* client.Tasks.createTask({ payload: { title: "warmup" } }).pipe(
76 Effect.timeout(requestTimeout),
77 Effect.retry(readinessRetry),
78 );
79 yield* client.Tasks.createTaskDO({ payload: { title: "warmup" } }).pipe(
80 Effect.timeout(requestTimeout),
81 Effect.retry(readinessRetry),
82 );
83 }),
84 ),
85 // just give it some extra time to propagate
86 Effect.tap(Effect.sleep("5 seconds")),
87 ),
88);
89afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
90
91test(
92 "deployed http-api worker handles createTask + getTask via typed HttpApiClient",
93 Effect.gen(function* () {
94 const { url } = yield* stack;
95 expect(url).toBeTypeOf("string");
96 const client = yield* makeClient(url);
97
98 const created = yield* client.Tasks.createTask({
99 payload: { title: "Write docs" },
100 }).pipe(Effect.timeout(requestTimeout), Effect.retry(readinessRetry));
101 expect(created.title).toBe("Write docs");
102 expect(created.completed).toBe(false);
103 expect(created.id).toBeTypeOf("string");
104
105 const fetched = yield* client.Tasks.getTask({
106 params: { id: created.id },
107 }).pipe(Effect.timeout(requestTimeout), Effect.retry(readinessRetry));
108 expect(fetched.id).toBe(created.id);
109 expect(fetched.title).toBe("Write docs");
110
111 // The "missing task" path must surface the worker's domain `TaskNotFound`,
112 // not a transient edge `HttpClientError` (a cold PoP that returns a raw
113 // 404/500 placeholder before the script resolves). Retry every *non-domain*
114 // failure through the readiness window and stop the instant we observe
115 // `TaskNotFound` — so we never re-run the real domain 404, but we do ride
116 // out cold-start transport errors.
117 const missing = yield* client.Tasks.getTask({
118 params: { id: "does-not-exist" },
119 }).pipe(
120 Effect.timeout(requestTimeout),
121 Effect.retry({
122 while: (e) => e._tag !== "TaskNotFound",
123 schedule: readinessRetry.schedule,
124 times: readinessRetry.times,
125 }),
126 Effect.flip,
127 );
128 expect(missing._tag).toBe("TaskNotFound");
129 if (missing._tag === "TaskNotFound") {
130 expect(missing.id).toBe("does-not-exist");
131 }
132 }).pipe(logLevel),
133 {
134 tags: [
135 "provider:cloudflare",
136 "provider:cloudflare:r2",
137 "provider:cloudflare:worker",
138 "live",
139 ],
140 timeout: testTimeout,
141 },
142);
143
144test(
145 "cors middleware adds Access-Control-Allow-Origin header on preflight",
146 Effect.gen(function* () {
147 const { url } = yield* stack;
148 // CORS preflight (OPTIONS) is transport-level and not part of the typed
149 // HttpApi surface, so this single check uses the raw HttpClient.
150 const client = yield* HttpClient.HttpClient;
151
152 const res = yield* requestUntilReady(
153 client.execute(
154 HttpClientRequest.make("OPTIONS")(url).pipe(
155 HttpClientRequest.setHeaders({
156 Origin: "https://example.com",
157 "Access-Control-Request-Method": "POST",
158 "Access-Control-Request-Headers": "content-type",
159 }),
160 ),
161 ),
162 );
163 expect(res.headers["access-control-allow-origin"]).toBeDefined();
164 }).pipe(logLevel),
165 {
166 tags: [
167 "provider:cloudflare",
168 "provider:cloudflare:r2",
169 "provider:cloudflare:worker",
170 "live",
171 ],
172 timeout: testTimeout,
173 },
174);
175
176test(
177 "cors middleware adds Access-Control-Allow-Origin header on actual requests",
178 Effect.gen(function* () {
179 const { url } = yield* stack;
180 const client = yield* HttpClient.HttpClient;
181
182 const res = yield* requestUntilReady(
183 client.execute(
184 HttpClientRequest.post(`${url}/`).pipe(
185 HttpClientRequest.setHeaders({ Origin: "https://example.com" }),
186 HttpClientRequest.bodyJsonUnsafe({ title: "cors-check" }),
187 ),
188 ),
189 );
190 expect(res.status).toBe(200);
191 expect(res.headers["access-control-allow-origin"]).toBeDefined();
192 }).pipe(logLevel),
193 {
194 tags: [
195 "provider:cloudflare",
196 "provider:cloudflare:r2",
197 "provider:cloudflare:worker",
198 "live",
199 ],
200 timeout: testTimeout,
201 },
202);
203
204test(
205 "concurrent createTask survives scope-lifecycle pressure",
206 Effect.gen(function* () {
207 const { url } = yield* stack;
208 const client = yield* makeClient(url);
209
210 const N = 200;
211 const results = yield* Effect.forEach(
212 Array.from({ length: N }, (_, i) => i),
213 (i) =>
214 Effect.gen(function* () {
215 const created = yield* client.Tasks.createTask({
216 payload: { title: `task-${i}` },
217 }).pipe(Effect.timeout(requestTimeout), Effect.retry(readinessRetry));
218 if (created.title !== `task-${i}`) {
219 return yield* Effect.fail(
220 new Error(`create ${i} title mismatch: ${created.title}`),
221 );
222 }
223 return created.id;
224 }),
225 { concurrency: 64 },
226 );
227
228 expect(results).toHaveLength(N);
229 expect(new Set(results).size).toBe(N);
230 }).pipe(logLevel),
231 {
232 tags: [
233 "provider:cloudflare",
234 "provider:cloudflare:r2",
235 "provider:cloudflare:worker",
236 "live",
237 ],
238 timeout: burstTimeout,
239 },
240);
241
242test(
243 "createTaskDO + getTaskDO round-trip 100x in parallel through the DO HttpApi",
244 Effect.gen(function* () {
245 const { url } = yield* stack;
246 const client = yield* makeClient(url);
247
248 const N = 100;
249 yield* Effect.forEach(
250 Array.from({ length: N }, (_, i) => i),
251 (i) =>
252 Effect.gen(function* () {
253 const title = `do-task-${i}`;
254 const created = yield* client.Tasks.createTaskDO({
255 payload: { title },
256 }).pipe(Effect.timeout(requestTimeout), Effect.retry(readinessRetry));
257 expect(created.title).toBe(title);
258 expect(created.completed).toBe(false);
259 expect(created.id).toBeTypeOf("string");
260
261 const fetched = yield* client.Tasks.getTaskDO({
262 params: { id: created.id },
263 }).pipe(Effect.timeout(requestTimeout), Effect.retry(readinessRetry));
264 expect(fetched.id).toBe(created.id);
265 expect(fetched.title).toBe(title);
266 }),
267 { concurrency: 32 },
268 );
269 }).pipe(logLevel),
270 {
271 tags: [
272 "provider:cloudflare",
273 "provider:cloudflare:r2",
274 "provider:cloudflare:worker",
275 "live",
276 ],
277 timeout: burstTimeout,
278 },
279);