Part 2: Add a Worker
In Part 1 you deployed an R2 Bucket. Now you’ll create a Cloudflare Worker that reads and writes objects in that bucket over HTTP.
Create the Worker file
Section titled “Create the Worker file”Create src/worker.ts. A Worker is a special kind of Resource — it
has both an infrastructure definition and a runtime implementation
expressed as an Effect.
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
export default Cloudflare.Worker( "Worker", { main: import.meta.url, }, Effect.gen(function* () { return { fetch: Effect.gen(function* () { return HttpServerResponse.text("Hello, world!"); }), }; }),);Bind the Bucket to the Worker
Section titled “Bind the Bucket to the Worker”Now let’s bind the R2 Bucket from Part 1 to our new Worker. The
problem is that the Bucket is declared inside the Stack’s generator
in alchemy.run.ts — we can’t import it from there.
A common pattern in Alchemy is to give each resource its own file.
Create src/bucket.ts:
import * as Cloudflare from "alchemy/Cloudflare";
export const Bucket = Cloudflare.R2.Bucket("Bucket");Update alchemy.run.ts to import it instead of declaring it inline:
import * as Alchemy from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import { Bucket } from "./src/bucket.ts";
export default Alchemy.Stack( "MyApp", { providers: Cloudflare.providers(), state: Cloudflare.state(), }, Effect.gen(function* () { const bucket = yield* Cloudflare.R2.Bucket("Bucket"); const bucket = yield* Bucket;
return { bucketName: bucket.bucketName, }; }),);Now the Worker can import Bucket and bind it in the Init phase:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";import { Bucket } from "./bucket.ts";
export default Cloudflare.Worker( "Worker", { main: import.meta.url, }, Effect.gen(function* () {Error ts(2345) ― const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket);
return { fetch: Effect.gen(function* () { return HttpServerResponse.text("Hello, world!"); }), }; }),);Provide the binding layer
Section titled “Provide the binding layer”The previous step showed a type error — Cloudflare.R2.ReadWriteBucket(Bucket)
requires the Cloudflare.R2.ReadWriteBucketBinding service. Fix it by piping
the outer Effect through Cloudflare.R2.ReadWriteBucketBinding:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";import { Bucket } from "./bucket.ts";
export default Cloudflare.Worker( "Worker", { main: import.meta.url }, Effect.gen(function* () { const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket);
return { fetch: Effect.gen(function* () { return HttpServerResponse.text("Hello, world!"); }), }; }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),);ReadWriteBucketBinding tells the Worker runtime how to look up the
underlying R2 binding from the Cloudflare environment. Without it, the
ReadWriteBucket binding wouldn’t know where to find the bucket at runtime.
Add the PUT handler
Section titled “Add the PUT handler”Let’s replace the placeholder response with a PUT route that stores
objects in the bucket. Add HttpServerRequest to access the incoming
request:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";import { Bucket } from "./bucket.ts";
export default Cloudflare.Worker( "Worker", { main: import.meta.url }, Effect.gen(function* () { const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket); return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const key = request.url.split("/").pop()!;
if (request.method === "PUT") { yield* bucket.put(key, request.stream, { contentLength: Number(request.headers["content-length"] ?? 0), }); return HttpServerResponse.empty({ status: 201 }); }
return HttpServerResponse.text("Hello, world!"); }), }; }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),);TypeScript flags a type error. The bucket.put call can fail with
R2Error, but a Worker’s fetch handler only allows
HttpServerError or HttpBodyError. Effect tracks this in the type
system — you can’t forget to handle it.
Handle R2 errors
Section titled “Handle R2 errors”Pipe the fetch Effect through Effect.catchTag to convert R2Error
into a 500 response:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";import { Bucket } from "./bucket.ts";
export default Cloudflare.Worker( "Worker", { main: import.meta.url }, Effect.gen(function* () { const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket); return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const key = request.url.split("/").pop()!;
if (request.method === "PUT") { yield* bucket.put(key, request.stream, { contentLength: Number(request.headers["content-length"] ?? 0), }); return HttpServerResponse.empty({ status: 201 }); }
return HttpServerResponse.text("Hello, world!"); }).pipe( Effect.catchTag("R2Error", (error) => Effect.succeed( HttpServerResponse.text(error.message, { status: 500 }), ), ), ), }; }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),);Effect.catchTag matches errors by their _tag field. If an R2
operation fails at runtime, the Worker returns a 500 instead of
crashing — and the type error disappears because R2Error is now
fully handled.
Add the GET handler
Section titled “Add the GET handler”Complete the fetch handler by reading objects from the bucket when the request isn’t a PUT:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";import { Bucket } from "./bucket.ts";
export default Cloudflare.Worker( "Worker", { main: import.meta.url }, Effect.gen(function* () { const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket); return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const key = request.url.split("/").pop()!;
if (request.method === "PUT") { yield* bucket.put(key, request.stream, { contentLength: Number(request.headers["content-length"] ?? 0), }); return HttpServerResponse.empty({ status: 201 }); }
const object = yield* bucket.get(key); if (object === null) { return HttpServerResponse.text("Not found", { status: 404 }); } const text = yield* object.text(); return HttpServerResponse.text(text); return HttpServerResponse.text("Hello, world!"); }).pipe( Effect.catchTag("R2Error", (error) => Effect.succeed( HttpServerResponse.text(error.message, { status: 500 }), ), ), ), }; }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),);The Worker now handles two routes:
PUT /:key— stores the request body in the bucketGET /:key— retrieves the object, returning 404 if missing
Because bucket.get also returns R2Error, the catchTag you added
in the previous step already covers it — no additional error handling
needed.
Wire the Worker into the Stack
Section titled “Wire the Worker into the Stack”Add the Worker to alchemy.run.ts and expose its URL as a stack
output:
import * as Alchemy from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import { Bucket } from "./src/bucket.ts";import Worker from "./src/worker.ts";
export default Alchemy.Stack( "MyApp", { providers: Cloudflare.providers(), state: Cloudflare.state(), }, Effect.gen(function* () { const bucket = yield* Bucket; const worker = yield* Worker;
return { bucketName: bucket.bucketName, url: worker.url, }; }),);Deploy
Section titled “Deploy”Deploy again. Alchemy detects the new Worker and the unchanged Bucket:
bun alchemy deploynpm run alchemy deploypnpm alchemy deployyarn alchemy deployPlan: 1 to create + Worker (Cloudflare.Worker) (1 bindings) + Bucket • Bucket (Cloudflare.R2.Bucket) Proceed? ◉ Yes ○ No • Bucket (Cloudflare.R2.Bucket) no change ✓ Worker (Cloudflare.Worker) created • Uploading worker (14.20 KB) ... • Enabling workers.dev subdomain... { bucketName: "myapp-bucket-a1b2c3d4e5", url: "https://myapp-worker-dev-you-abc123.workers.dev", }
Try it out
Section titled “Try it out”Use curl to write and read an object:
# Store an objectcurl -X PUT https://myapp-worker-dev-you-abc123.workers.dev/hello.txt \ -d 'Hello, world!'
# Retrieve itcurl https://myapp-worker-dev-you-abc123.workers.dev/hello.txt# → Hello, world!You now have:
- A Cloudflare Worker with GET and PUT routes
- An R2 Bucket bound to the Worker
- Stack outputs showing both the bucket name and worker URL
In Part 3, you’ll learn about stages and state stores so multiple developers (and CI) can deploy isolated environments.