| 1 | import { Action } from "@/Action"; |
| 2 | import * as Cloudflare from "@/Cloudflare/index.ts"; |
| 3 | import * as Alchemy from "@/index.ts"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import * as r2 from "@distilled.cloud/cloudflare/r2"; |
| 6 | import { expect } from "alchemy-test"; |
| 7 | import * as Data from "effect/Data"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import { MinimumLogLevel } from "effect/References"; |
| 10 | import * as Schedule from "effect/Schedule"; |
| 11 | import * as Stream from "effect/Stream"; |
| 12 | import * as HttpClient from "effect/http/HttpClient"; |
| 13 | import * as pathe from "pathe"; |
| 14 | import { 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). |
| 19 | const { test } = Test.make({ |
| 20 | providers: Cloudflare.providers(), |
| 21 | dev: true, |
| 22 | }); |
| 23 | |
| 24 | const logLevel = Effect.provideService( |
| 25 | MinimumLogLevel, |
| 26 | process.env.DEBUG ? "Debug" : "Info", |
| 27 | ); |
| 28 | |
| 29 | class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{ |
| 30 | status: number; |
| 31 | }> {} |
| 32 | |
| 33 | const 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 | */ |
| 65 | test.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 | */ |
| 128 | test.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 | */ |
| 210 | test.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 | ); |