1import type { WriteBucketClient } from "@/Cloudflare/R2/WriteBucket.ts";
2import * as Effect from "effect/Effect";
3import type * as HttpServerRequest from "effect/http/HttpServerRequest";
4import * as HttpServerResponse from "effect/http/HttpServerResponse";
5
6/**
7 * Shared write-side routes exercised by both the binding and HTTP fixtures so
8 * every (HTTP-supported) method of {@link WriteBucketClient} is driven over
9 * `fetch`:
10 *
11 * - `PUT /put?key=` — `put(key, body)` (returns the object key to prove the
12 * call resolved an `R2Object`).
13 * - `DELETE /del?key=` — `delete(key)` (single).
14 * - `DELETE /del-many?keys=a,b,c` — `delete(keys)` (batch). Keys travel as a
15 * comma-separated query param rather than a request body because DELETE
16 * bodies are unreliable across `fetch`/the CF edge.
17 *
18 * Returns `undefined` when the path is not a write route so the caller can
19 * fall through (used by the read-write fixtures).
20 */
21export const writeRoutes = (
22 r2: WriteBucketClient,
23 request: HttpServerRequest.HttpServerRequest,
24 url: URL,
25) =>
26 Effect.gen(function* () {
27 if (request.method === "PUT" && url.pathname === "/put") {
28 const key = url.searchParams.get("key") ?? "";
29 const body = yield* request.text;
30 const object = yield* r2.put(key, body).pipe(Effect.orDie);
31 return yield* HttpServerResponse.json({
32 ok: true,
33 key: object?.key ?? key,
34 });
35 }
36 if (request.method === "DELETE" && url.pathname === "/del") {
37 const key = url.searchParams.get("key") ?? "";
38 yield* r2.delete(key).pipe(Effect.orDie);
39 return yield* HttpServerResponse.json({ ok: true });
40 }
41 if (request.method === "DELETE" && url.pathname === "/del-many") {
42 const keys = (url.searchParams.get("keys") ?? "")
43 .split(",")
44 .filter((k) => k.length > 0);
45 yield* r2.delete(keys).pipe(Effect.orDie);
46 return yield* HttpServerResponse.json({ ok: true });
47 }
48 // Multipart upload (native binding only — the HTTP client `Effect.die`s
49 // these). R2 requires every part except the last to be ≥ 5 MiB, so the
50 // first part is exactly 5 MiB and the tail is a tiny final part. The
51 // worker generates the bytes internally so the test payload stays small.
52 if (request.method === "POST" && url.pathname === "/mpu") {
53 const key = url.searchParams.get("key") ?? "";
54 const part = new Uint8Array(5 * 1024 * 1024).fill(65);
55 const upload = yield* r2.createMultipartUpload(key).pipe(Effect.orDie);
56 const p1 = yield* upload.uploadPart(1, part).pipe(Effect.orDie);
57 const p2 = yield* upload.uploadPart(2, "tail").pipe(Effect.orDie);
58 const object = yield* upload.complete([p1, p2]).pipe(Effect.orDie);
59 return yield* HttpServerResponse.json({ ok: true, size: object.size });
60 }
61 if (request.method === "POST" && url.pathname === "/mpu-abort") {
62 const key = url.searchParams.get("key") ?? "";
63 const part = new Uint8Array(5 * 1024 * 1024).fill(66);
64 const upload = yield* r2.createMultipartUpload(key).pipe(Effect.orDie);
65 yield* upload.uploadPart(1, part).pipe(Effect.orDie);
66 yield* upload.abort().pipe(Effect.orDie);
67 return yield* HttpServerResponse.json({ ok: true });
68 }
69 if (request.method === "POST" && url.pathname === "/mpu-resume") {
70 const key = url.searchParams.get("key") ?? "";
71 const part = new Uint8Array(5 * 1024 * 1024).fill(67);
72 const created = yield* r2.createMultipartUpload(key).pipe(Effect.orDie);
73 const upload = yield* r2
74 .resumeMultipartUpload(key, created.uploadId)
75 .pipe(Effect.orDie);
76 const p1 = yield* upload.uploadPart(1, part).pipe(Effect.orDie);
77 const p2 = yield* upload.uploadPart(2, "tail").pipe(Effect.orDie);
78 const object = yield* upload.complete([p1, p2]).pipe(Effect.orDie);
79 return yield* HttpServerResponse.json({ ok: true, size: object.size });
80 }
81 return undefined;
82 });