1import { Action } from "@/Action";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import * as Alchemy from "@/index.ts";
4import * as Test from "@/Test/Alchemy";
5import * as r2 from "@distilled.cloud/cloudflare/r2";
6import { expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11import * as Stream from "effect/Stream";
12import * as HttpClient from "effect/http/HttpClient";
13import * as pathe from "pathe";
14import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment.ts";
15
16// `dev: true` runs local providers behind the RPC sidecar proxy by default,
17// matching the process topology of the real `alchemy dev` command (see
18// MakeOptions.sidecar in Test/Core.ts).
19const { test } = Test.make({
20 providers: Cloudflare.providers(),
21 dev: true,
22});
23
24const logLevel = Effect.provideService(
25 MinimumLogLevel,
26 process.env.DEBUG ? "Debug" : "Info",
27);
28
29class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
30 status: number;
31}> {}
32
33const getJsonReady = (url: string) =>
34 Effect.gen(function* () {
35 const client = yield* HttpClient.HttpClient;
36 const res = yield* client.get(url).pipe(
37 Effect.flatMap((res) =>
38 res.status === 200
39 ? Effect.succeed(res)
40 : Effect.fail(new WorkerNotReady({ status: res.status })),
41 ),
42 Effect.retry({
43 while: (e): e is WorkerNotReady => e instanceof WorkerNotReady,
44 // Cap the backoff: an uncapped exponential over 10 recurs sums to
45 // ~8.5 minutes and turns a persistent non-200 into an apparent hang.
46 schedule: Schedule.max([
47 Schedule.min([
48 Schedule.exponential("500 millis"),
49 Schedule.spaced("2 seconds"),
50 ]),
51 Schedule.recurs(10),
52 ]),
53 }),
54 );
55 return yield* res.json;
56 }).pipe(Effect.orDie);
57
58/**
59 * Under `alchemy dev` the R2 Bucket resource is emulated by the local
60 * provider (a `dev:`-prefixed bucket name, no cloud API calls) and the
61 * worker's `r2_bucket` binding is lowered onto the local workerd R2
62 * simulator. This exercises the full local roundtrip: put / get / head /
63 * list / delete through the native `env` binding.
64 */
65test.provider(
66 "R2 bucket binding round-trips against the local simulator",
67 (stack) =>
68 Effect.gen(function* () {
69 yield* stack.destroy();
70
71 const deployed = yield* stack.deploy(
72 Effect.gen(function* () {
73 const bucket = yield* Cloudflare.R2.Bucket("LocalBucket", {
74 forceDestroy: true,
75 });
76 const worker = yield* Cloudflare.Worker("r2-local-worker", {
77 main: pathe.resolve(
78 import.meta.dirname,
79 "fixtures/r2-local-worker.ts",
80 ),
81 env: { BUCKET: bucket },
82 });
83 return { bucket, worker };
84 }),
85 );
86
87 // The local provider fabricates a `dev:` name — proof no cloud call
88 // ran — and the worker serves from the local dev proxy.
89 expect(deployed.bucket.bucketName).toMatch(/^dev:/);
90 expect(deployed.worker.url).toMatch(/^http:\/\/localhost:\d+$/);
91
92 const body = (yield* getJsonReady(
93 `${deployed.worker.url}/roundtrip`,
94 )) as {
95 text: string;
96 etag: string | null;
97 size: number | null;
98 keys: string[];
99 afterDelete: boolean;
100 };
101 expect(body.text).toBe("hello r2");
102 expect(body.etag).toBeTruthy();
103 expect(body.size).toBe("hello r2".length);
104 expect(body.keys).toEqual(["greeting.txt"]);
105 expect(body.afterDelete).toBe(true);
106
107 yield* stack.destroy();
108 }).pipe(logLevel),
109 {
110 tags: [
111 "provider:cloudflare",
112 "provider:cloudflare:r2",
113 "provider:cloudflare:worker",
114 "local",
115 ],
116 timeout: 120_000,
117 },
118);
119
120/**
121 * The `*Local` R2 capability layers reach the local simulator through an
122 * ephemeral workerd gateway that emulates the R2 REST API over the native
123 * binding. An Action seeds (put/get/head/list/delete) a `dev:` bucket, and
124 * the worker's native binding then reads the same simulator storage —
125 * proving Node-side capability clients and worker bindings share one local
126 * data plane.
127 */
128test.provider(
129 "ReadWriteBucketLocal Action seeds the local simulator in dev",
130 (stack) =>
131 Effect.gen(function* () {
132 yield* stack.destroy();
133
134 const deployed = yield* stack.deploy(
135 Effect.gen(function* () {
136 const bucket = yield* Cloudflare.R2.Bucket("ActionSeededBucket", {
137 forceDestroy: true,
138 });
139
140 const Seed = Action(
141 "Seed",
142 Effect.gen(function* () {
143 const client = yield* Cloudflare.R2.ReadWriteBucket(bucket);
144 return Effect.fn(function* () {
145 yield* client.put("seeded.txt", "from-action", {
146 httpMetadata: { contentType: "text/plain" },
147 });
148 yield* client.put("other.txt", "value");
149 const object = yield* client.get("seeded.txt");
150 const text = object === null ? null : yield* object.text();
151 const head = yield* client.head("seeded.txt");
152 const list = yield* client.list();
153 yield* client.delete("other.txt");
154 const afterDelete = yield* client.get("other.txt");
155 return {
156 text,
157 contentType: head?.httpMetadata?.contentType ?? null,
158 keys: list.objects.map((o) => o.key),
159 afterDelete: afterDelete === null,
160 };
161 });
162 }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketLocal)),
163 );
164 const seeded = yield* Seed({});
165
166 const worker = yield* Cloudflare.Worker("r2-action-worker", {
167 main: pathe.resolve(
168 import.meta.dirname,
169 "fixtures/r2-local-worker.ts",
170 ),
171 env: { BUCKET: bucket },
172 });
173 return { bucket, worker, seeded };
174 }),
175 );
176
177 expect(deployed.bucket.bucketName).toMatch(/^dev:/);
178 expect(deployed.seeded.text).toBe("from-action");
179 expect(deployed.seeded.contentType).toBe("text/plain");
180 expect(deployed.seeded.keys.sort()).toEqual(["other.txt", "seeded.txt"]);
181 expect(deployed.seeded.afterDelete).toBe(true);
182
183 // The worker's native binding reads the same simulator storage the
184 // Action's gateway wrote to.
185 const body = (yield* getJsonReady(
186 `${deployed.worker.url}/get?key=seeded.txt`,
187 )) as { text: string | null };
188 expect(body.text).toBe("from-action");
189
190 yield* stack.destroy();
191 }).pipe(logLevel),
192 {
193 tags: [
194 "provider:cloudflare",
195 "provider:cloudflare:r2",
196 "provider:cloudflare:worker",
197 "local",
198 ],
199 timeout: 120_000,
200 },
201);
202
203/**
204 * `Alchemy.remote()` opts the bucket OUT of local emulation: even under
205 * `alchemy dev` the bucket is created on real Cloudflare (a real bucket
206 * name, not `dev:`-prefixed) and the worker's `r2_bucket` binding proxies
207 * to it remotely. An out-of-band read through the cloud API proves the
208 * worker's write landed in the real bucket, and destroy removes it.
209 */
210test.provider(
211 "Alchemy.remote() bucket runs live in dev",
212 (stack) =>
213 Effect.gen(function* () {
214 yield* stack.destroy();
215
216 const deployed = yield* stack.deploy(
217 Effect.gen(function* () {
218 const bucket = yield* Cloudflare.R2.Bucket("LiveDevBucket", {
219 forceDestroy: true,
220 }).pipe(Alchemy.remote());
221 const worker = yield* Cloudflare.Worker("r2-live-worker", {
222 main: pathe.resolve(
223 import.meta.dirname,
224 "fixtures/r2-local-worker.ts",
225 ),
226 env: { BUCKET: bucket },
227 });
228 return { bucket, worker };
229 }),
230 );
231
232 // A real bucket name — the live provider created it on Cloudflare.
233 expect(deployed.bucket.bucketName).not.toMatch(/^dev:/);
234
235 const seeded = (yield* getJsonReady(`${deployed.worker.url}/seed`)) as {
236 etag: string | null;
237 };
238 expect(seeded.etag).toBeTruthy();
239
240 // Out-of-band: the object is visible through the cloud API — the
241 // remote-proxied binding really hit the live bucket.
242 const { accountId } = yield* yield* CloudflareEnvironment;
243 const object = yield* r2.getObject({
244 accountId,
245 bucketName: deployed.bucket.bucketName,
246 objectName: "seed.txt",
247 });
248 const text = yield* object.body.pipe(Stream.decodeText, Stream.mkString);
249 expect(text).toBe("seeded by worker");
250
251 yield* stack.destroy();
252
253 // Destroy emptied and deleted the real bucket (stamped live mode).
254 const gone = yield* r2
255 .getBucket({ accountId, bucketName: deployed.bucket.bucketName })
256 .pipe(
257 Effect.as(false),
258 Effect.catchTag("NoSuchBucket", () => Effect.succeed(true)),
259 );
260 expect(gone).toBe(true);
261 }).pipe(logLevel),
262 {
263 tags: [
264 "provider:cloudflare",
265 "provider:cloudflare:r2",
266 "provider:cloudflare:worker",
267 "live",
268 ],
269 timeout: 120_000,
270 },
271);