| 1 | --- |
| 2 | title: Effect HTTP API |
| 3 | description: Build a schema-validated HTTP API with Effect's HttpApi module and deploy it as a Cloudflare Worker. |
| 4 | sidebar: |
| 5 | order: 3 |
| 6 | --- |
| 7 | |
| 8 | Effect HTTP is the trust-boundary modality: schema-validated REST |
| 9 | endpoints — real URLs, path params, query strings, payloads — that |
| 10 | any HTTP client can call, no Effect or TypeScript required on the |
| 11 | consumer's side. This page deploys one to a Cloudflare Worker; the |
| 12 | concept home is [Effect HTTP](/apis/effect-http), and |
| 13 | [RPC](/apis) covers choosing between the modalities. |
| 14 | |
| 15 | In the [tutorial](/cloudflare/tutorial/part-2), you built a Worker with manual |
| 16 | `if/else` routing and raw request parsing. That works, but as your |
| 17 | API grows you lose type safety at the boundary — request payloads |
| 18 | aren't validated, response shapes aren't enforced, and errors slip |
| 19 | through untyped. |
| 20 | |
| 21 | Effect's `HttpApi` module solves this. You declare endpoints with |
| 22 | schemas for payloads, responses, and errors, then implement handlers |
| 23 | against those schemas. The result is an `HttpEffect` — the same type |
| 24 | a Worker's `fetch` expects — so it plugs in directly. |
| 25 | |
| 26 | The mental model we'll follow is: |
| 27 | |
| 28 | 1. **Define the schema and API outside the Worker.** Both are pure |
| 29 | descriptions and can be imported by clients. |
| 30 | 2. **Construct the service inside the Worker's Construction phase.** |
| 31 | The Construction phase runs at plan time *and* runtime, so we only do |
| 32 | pure construction here — we never `yield*` something that needs |
| 33 | a request to exist. |
| 34 | 3. **Return `{ fetch }`** where `fetch` is an `HttpEffect` produced |
| 35 | by `HttpRouter.toHttpEffect`. That's the value Workers invoke on |
| 36 | every request. |
| 37 | 4. **Bonus:** deploy, grab the URL, and call the API from a fully |
| 38 | typed client. |
| 39 | |
| 40 | ## 1. Define the schema |
| 41 | |
| 42 | Start with a domain model. The schema lives outside the Worker so |
| 43 | the same file can be imported by clients without pulling in any |
| 44 | runtime code. |
| 45 | |
| 46 | ```typescript |
| 47 | // src/task.ts |
| 48 | import * as Schema from "effect/Schema"; |
| 49 | |
| 50 | export class Task extends Schema.Class<Task>("Task")({ |
| 51 | id: Schema.String, |
| 52 | title: Schema.String, |
| 53 | completed: Schema.Boolean, |
| 54 | }) {} |
| 55 | |
| 56 | export class TaskNotFound extends Schema.TaggedErrorClass<TaskNotFound>()( |
| 57 | "TaskNotFound", |
| 58 | { id: Schema.String }, |
| 59 | { httpApiStatus: 404 }, |
| 60 | ) {} |
| 61 | ``` |
| 62 | |
| 63 | `Schema.Class` gives you a runtime-validated class with an inferred |
| 64 | TypeScript type. `Schema.TaggedErrorClass` gives you a typed error you |
| 65 | can return from handlers and discriminate against on the client — |
| 66 | `httpApiStatus: 404` maps it to a 404 response instead of a generic 500. |
| 67 | |
| 68 | ## 2. Declare the API |
| 69 | |
| 70 | Endpoints are declarations — they describe `(method, path, |
| 71 | params, payload, success, error)` without implementing anything yet. Putting |
| 72 | them in their own file keeps the spec importable from both the |
| 73 | server *and* a typed client. |
| 74 | |
| 75 | ```typescript |
| 76 | // src/api.ts |
| 77 | import * as Schema from "effect/Schema"; |
| 78 | import * as HttpApi from "effect/http-api/HttpApi"; |
| 79 | import * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint"; |
| 80 | import * as HttpApiGroup from "effect/http-api/HttpApiGroup"; |
| 81 | import { Task, TaskNotFound } from "./task.ts"; |
| 82 | |
| 83 | export const decodeTask = Schema.decodeUnknownEffect(Task); |
| 84 | export const encodeTask = Schema.encodeUnknownSync(Task); |
| 85 | |
| 86 | const TaskParams = Schema.Struct({ |
| 87 | id: Schema.String, |
| 88 | }); |
| 89 | |
| 90 | const getTask = HttpApiEndpoint.get("getTask", "/:id", { |
| 91 | params: TaskParams, |
| 92 | success: Task, |
| 93 | error: TaskNotFound, |
| 94 | }); |
| 95 | |
| 96 | const createTask = HttpApiEndpoint.post("createTask", "/", { |
| 97 | success: Task, |
| 98 | payload: Schema.Struct({ |
| 99 | title: Schema.String, |
| 100 | }), |
| 101 | }); |
| 102 | |
| 103 | export class TasksGroup extends HttpApiGroup.make("Tasks") |
| 104 | .add(getTask) |
| 105 | .add(createTask) {} |
| 106 | |
| 107 | export class TaskApi extends HttpApi.make("TaskApi").add(TasksGroup) {} |
| 108 | ``` |
| 109 | |
| 110 | Nothing executes yet — `TaskApi` is purely a value-level description. |
| 111 | The same `TaskApi` constant is what we'll hand to the client at the |
| 112 | end of this guide. |
| 113 | |
| 114 | ## 3. Build the Worker |
| 115 | |
| 116 | Now we wire it up. Create `src/worker.ts` with an empty Construction phase: |
| 117 | |
| 118 | ```typescript |
| 119 | // src/worker.ts |
| 120 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 121 | import * as Effect from "effect/Effect"; |
| 122 | |
| 123 | export default Cloudflare.Worker( |
| 124 | "Worker", |
| 125 | { main: import.meta.url }, |
| 126 | Effect.gen(function* () { |
| 127 | return {}; |
| 128 | }), |
| 129 | ); |
| 130 | ``` |
| 131 | |
| 132 | The generator inside `Cloudflare.Worker` is the **Construction phase**. It |
| 133 | runs both at *plan time* (when Alchemy builds the deployment graph) |
| 134 | and at *runtime* (when the Worker boots a fresh isolate). Anything |
| 135 | you `yield*` here must be safe in both contexts — typically resource |
| 136 | binding factories like `R2.ReadWriteBucket(...)`, never per-request work. |
| 137 | |
| 138 | ### 3a. Bind an R2 bucket for storage |
| 139 | |
| 140 | Tasks need to live somewhere durable. Declare an `Bucket` resource |
| 141 | and bind it inside the constructor — `bind()` returns a typed handle whose |
| 142 | `get` / `put` / `delete` / `list` methods we'll call from the |
| 143 | handlers below. |
| 144 | |
| 145 | ```typescript |
| 146 | // src/bucket.ts |
| 147 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 148 | |
| 149 | export const Tasks = Cloudflare.R2.Bucket("Tasks"); |
| 150 | ``` |
| 151 | |
| 152 | ```diff lang="typescript" |
| 153 | +import { Tasks } from "./bucket.ts"; |
| 154 | |
| 155 | export default Cloudflare.Worker( |
| 156 | "Worker", |
| 157 | { main: import.meta.url }, |
| 158 | Effect.gen(function* () { |
| 159 | + const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks); |
| 160 | |
| 161 | return {}; |
| 162 | }), |
| 163 | ); |
| 164 | ``` |
| 165 | |
| 166 | We'll provide the runtime side of this binding |
| 167 | (`Cloudflare.R2.ReadWriteBucketBinding`) in step 3c when we wire up the |
| 168 | `fetch` handler. |
| 169 | |
| 170 | ### 3b. Construct the handler group inside the constructor |
| 171 | |
| 172 | `HttpApiBuilder.group` *constructs* a `Layer` that wires handlers |
| 173 | into the API spec. It's pure — it doesn't run them. That makes it |
| 174 | safe to call inside the constructor. |
| 175 | |
| 176 | > Don't `yield*` `HttpApiBuilder.layer(TaskApi)` here — building the |
| 177 | > layer is fine, but actually executing the server requires an |
| 178 | > incoming request. The constructor only does construction; the work happens |
| 179 | > later, on each `fetch` call. |
| 180 | |
| 181 | ```diff lang="typescript" |
| 182 | Effect.gen(function* () { |
| 183 | const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks); |
| 184 | |
| 185 | + const tasksGroup = HttpApiBuilder.group(TaskApi, "Tasks", (handlers) => |
| 186 | + handlers |
| 187 | + .handle("getTask", ({ params }) => |
| 188 | + Effect.gen(function* () { |
| 189 | + const object = yield* tasks.get(params.id); |
| 190 | + if (!object) { |
| 191 | + return yield* Effect.fail(new TaskNotFound({ id: params.id })); |
| 192 | + } |
| 193 | + return Schema.decodeUnknownSync(Task)(JSON.parse(yield* object.text())); |
| 194 | + }).pipe(Effect.orDie), |
| 195 | + ) |
| 196 | + .handle("createTask", ({ payload }) => |
| 197 | + Effect.gen(function* () { |
| 198 | + const task = new Task({ |
| 199 | + id: crypto.randomUUID(), |
| 200 | + title: payload.title, |
| 201 | + completed: false, |
| 202 | + }); |
| 203 | + yield* tasks.put(task.id, JSON.stringify(task)); |
| 204 | + return task; |
| 205 | + }).pipe(Effect.orDie), |
| 206 | + ), |
| 207 | + ); |
| 208 | |
| 209 | return {}; |
| 210 | }), |
| 211 | ``` |
| 212 | |
| 213 | Each handler receives a typed request. `params.id` is a `string` |
| 214 | because that's what the endpoint declared, and the return type must |
| 215 | satisfy `Task` (or fail with `TaskNotFound`). Mismatches are caught |
| 216 | at compile time. |
| 217 | |
| 218 | `tasks.get` and `tasks.put` can fail with `R2Error`, which isn't in |
| 219 | either endpoint's declared error set. `Effect.orDie` converts those |
| 220 | into defects so the HttpApi runtime turns them into 500 responses — |
| 221 | keeping the typed error channel reserved for `TaskNotFound`. |
| 222 | |
| 223 | ### 3c. Return `{ fetch }` |
| 224 | |
| 225 | The return value of the constructor is the *Worker's surface* — for a |
| 226 | fetch-style Worker that means an object with a `fetch` field. The |
| 227 | value of `fetch` must be an `HttpEffect`: an `Effect` that, given an |
| 228 | `HttpServerRequest`, produces an `HttpServerResponse`. |
| 229 | |
| 230 | We assemble it in three layers, then convert: |
| 231 | |
| 232 | - `HttpApiBuilder.layer(TaskApi)` — the top-level API layer. |
| 233 | - `Layer.provide(tasksGroup)` — plug in the handlers we just built. |
| 234 | - `Layer.provide([Etag.layer, HttpPlatformStub, Path.layer])` — |
| 235 | platform services the builder needs (content negotiation, ETag |
| 236 | generation, path handling). |
| 237 | - `yield* HttpRouter.toHttpEffect(...)` — build the assembled Layer |
| 238 | and yield the `HttpEffect`. |
| 239 | |
| 240 | `HttpPlatformStub` stands in for `HttpPlatform.layer`, which requires |
| 241 | a `FileSystem` and therefore can't run on Workers — there is no |
| 242 | filesystem in the isolate. Define it once at module scope: |
| 243 | |
| 244 | ```typescript |
| 245 | const HttpPlatformStub = Layer.succeed(HttpPlatform.HttpPlatform, { |
| 246 | fileResponse: () => Effect.die("HttpPlatform.fileResponse not supported"), |
| 247 | fileWebResponse: () => |
| 248 | Effect.die("HttpPlatform.fileWebResponse not supported"), |
| 249 | }); |
| 250 | ``` |
| 251 | |
| 252 | ```diff lang="typescript" |
| 253 | return { |
| 254 | + fetch: yield* HttpRouter.toHttpEffect( |
| 255 | + HttpApiBuilder.layer(TaskApi).pipe( |
| 256 | + Layer.provide(tasksGroup), |
| 257 | + Layer.provide([Etag.layer, HttpPlatformStub, Path.layer]), |
| 258 | + ), |
| 259 | + ), |
| 260 | }; |
| 261 | ``` |
| 262 | |
| 263 | `HttpRouter.toHttpEffect` returns an `Effect` that builds the Layer |
| 264 | and hands back the request handler — yielding it once in the constructor means |
| 265 | route construction happens at boot, not per request. |
| 266 | |
| 267 | ### 3d. Enable CORS |
| 268 | |
| 269 | `HttpRouter.cors(...)` returns a `Layer` that registers a global |
| 270 | router middleware. Plug it in alongside the platform services with |
| 271 | another `Layer.provide`. |
| 272 | |
| 273 | ```diff lang="typescript" |
| 274 | return { |
| 275 | fetch: yield* HttpRouter.toHttpEffect( |
| 276 | HttpApiBuilder.layer(TaskApi).pipe( |
| 277 | Layer.provide(tasksGroup), |
| 278 | Layer.provide([Etag.layer, HttpPlatformStub, Path.layer]), |
| 279 | + Layer.provide(HttpRouter.cors({ |
| 280 | + allowedOrigins: ["*"], |
| 281 | + allowedMethods: ["GET", "POST", "OPTIONS"], |
| 282 | + allowedHeaders: ["Content-Type"], |
| 283 | + })), |
| 284 | ), |
| 285 | ), |
| 286 | }; |
| 287 | ``` |
| 288 | |
| 289 | The middleware wraps every response — including the automatic |
| 290 | `OPTIONS` preflight — with the configured `Access-Control-*` headers. |
| 291 | Because it's a `Layer<never, never, HttpRouter>`, it composes the same |
| 292 | way as the rest of the API stack: order doesn't matter, and there's |
| 293 | no per-handler plumbing. |
| 294 | |
| 295 | ### Putting it together |
| 296 | |
| 297 | Here's the complete `src/worker.ts`: |
| 298 | |
| 299 | ```typescript |
| 300 | // src/worker.ts |
| 301 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 302 | import * as Effect from "effect/Effect"; |
| 303 | import * as Layer from "effect/Layer"; |
| 304 | import * as Path from "effect/Path"; |
| 305 | import * as Schema from "effect/Schema"; |
| 306 | import * as Etag from "effect/http/Etag"; |
| 307 | import * as HttpPlatform from "effect/http/HttpPlatform"; |
| 308 | import * as HttpRouter from "effect/http/HttpRouter"; |
| 309 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 310 | import { TaskApi } from "./api.ts"; |
| 311 | import { Tasks } from "./bucket.ts"; |
| 312 | import { Task, TaskNotFound } from "./task.ts"; |
| 313 | |
| 314 | const HttpPlatformStub = Layer.succeed(HttpPlatform.HttpPlatform, { |
| 315 | fileResponse: () => Effect.die("HttpPlatform.fileResponse not supported"), |
| 316 | fileWebResponse: () => |
| 317 | Effect.die("HttpPlatform.fileWebResponse not supported"), |
| 318 | }); |
| 319 | |
| 320 | export default Cloudflare.Worker( |
| 321 | "Worker", |
| 322 | { main: import.meta.url }, |
| 323 | Effect.gen(function* () { |
| 324 | const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks); |
| 325 | |
| 326 | const tasksGroup = HttpApiBuilder.group(TaskApi, "Tasks", (handlers) => |
| 327 | handlers |
| 328 | .handle("getTask", ({ params }) => |
| 329 | Effect.gen(function* () { |
| 330 | const object = yield* tasks.get(params.id); |
| 331 | if (!object) { |
| 332 | return yield* Effect.fail(new TaskNotFound({ id: params.id })); |
| 333 | } |
| 334 | return Schema.decodeUnknownSync(Task)( |
| 335 | JSON.parse(yield* object.text()), |
| 336 | ); |
| 337 | }).pipe(Effect.orDie), |
| 338 | ) |
| 339 | .handle("createTask", ({ payload }) => |
| 340 | Effect.gen(function* () { |
| 341 | const task = new Task({ |
| 342 | id: crypto.randomUUID(), |
| 343 | title: payload.title, |
| 344 | completed: false, |
| 345 | }); |
| 346 | yield* tasks.put(task.id, JSON.stringify(task)); |
| 347 | return task; |
| 348 | }).pipe(Effect.orDie), |
| 349 | ), |
| 350 | ); |
| 351 | |
| 352 | return { |
| 353 | fetch: yield* HttpRouter.toHttpEffect( |
| 354 | HttpApiBuilder.layer(TaskApi).pipe( |
| 355 | Layer.provide(tasksGroup), |
| 356 | Layer.provide([Etag.layer, HttpPlatformStub, Path.layer]), |
| 357 | Layer.provide( |
| 358 | HttpRouter.cors({ |
| 359 | allowedOrigins: ["*"], |
| 360 | allowedMethods: ["GET", "POST", "OPTIONS"], |
| 361 | allowedHeaders: ["Content-Type"], |
| 362 | }), |
| 363 | ), |
| 364 | ), |
| 365 | ), |
| 366 | }; |
| 367 | }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)), |
| 368 | ); |
| 369 | ``` |
| 370 | |
| 371 | ## 4. Add to the Stack |
| 372 | |
| 373 | ```typescript |
| 374 | // alchemy.run.ts |
| 375 | import * as Alchemy from "alchemy"; |
| 376 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 377 | import * as Effect from "effect/Effect"; |
| 378 | import Worker from "./src/worker.ts"; |
| 379 | |
| 380 | export default Alchemy.Stack( |
| 381 | "TaskApi", |
| 382 | { providers: Cloudflare.providers(), state: Cloudflare.state() }, |
| 383 | Effect.gen(function* () { |
| 384 | const worker = yield* Worker; |
| 385 | return { url: worker.url }; |
| 386 | }), |
| 387 | ); |
| 388 | ``` |
| 389 | |
| 390 | ## 5. Deploy |
| 391 | |
| 392 | ```sh |
| 393 | alchemy deploy |
| 394 | ``` |
| 395 | |
| 396 | You can hit it directly with `curl`: |
| 397 | |
| 398 | ```sh |
| 399 | curl -X POST https://your-worker.workers.dev/ \ |
| 400 | -H "Content-Type: application/json" \ |
| 401 | -d '{"title": "Write docs"}' |
| 402 | # → {"id":"...","title":"Write docs","completed":false} |
| 403 | |
| 404 | curl https://your-worker.workers.dev/<id> |
| 405 | # → {"id":"...","title":"Write docs","completed":false} |
| 406 | ``` |
| 407 | |
| 408 | Invalid payloads get automatic 400 responses with structured |
| 409 | validation errors — no manual checking needed. |
| 410 | |
| 411 | ## Bonus: call it from a typed client |
| 412 | |
| 413 | Because `TaskApi` is just a value, the same spec drives a fully |
| 414 | typed client. There's no codegen step — `HttpApiClient.make` |
| 415 | produces methods whose argument and return types come straight from |
| 416 | the endpoint schemas. |
| 417 | |
| 418 | ```typescript |
| 419 | // scripts/client.ts |
| 420 | import * as Effect from "effect/Effect"; |
| 421 | import * as HttpApiClient from "effect/http-api/HttpApiClient"; |
| 422 | import { TaskApi } from "../src/api.ts"; |
| 423 | |
| 424 | const program = Effect.gen(function* () { |
| 425 | const client = yield* HttpApiClient.make(TaskApi, { |
| 426 | baseUrl: process.env.TASK_API_URL!, |
| 427 | }); |
| 428 | |
| 429 | const created = yield* client.Tasks.createTask({ |
| 430 | payload: { title: "Write docs" }, |
| 431 | }); |
| 432 | console.log("Created:", created.id); |
| 433 | |
| 434 | const fetched = yield* client.Tasks.getTask({ |
| 435 | params: { id: created.id }, |
| 436 | }); |
| 437 | console.log("Fetched:", fetched.title); |
| 438 | }); |
| 439 | |
| 440 | Effect.runPromise(program); |
| 441 | ``` |
| 442 | |
| 443 | Get the URL from the deploy output and run it: |
| 444 | |
| 445 | ```sh |
| 446 | TASK_API_URL=https://your-worker.workers.dev bun scripts/client.ts |
| 447 | ``` |
| 448 | |
| 449 | `client.Tasks.getTask` returns `Effect<Task, TaskNotFound | HttpClientError>`. |
| 450 | The `TaskNotFound` branch is a real typed value you can pattern-match |
| 451 | on, not an HTTP status code you have to interpret. |
| 452 | |
| 453 | ## Bonus: route some endpoints to a Durable Object |
| 454 | |
| 455 | The same `HttpApi` machinery can run *inside* a Durable Object. The |
| 456 | DO becomes a typed sub-API that the Worker delegates to, while the |
| 457 | public `TaskApi` shape remains a single value clients can import. |
| 458 | |
| 459 | ### Define a DO-side group |
| 460 | |
| 461 | The DO exposes its own group built from the same endpoint |
| 462 | declarations. Add two more endpoints to the public spec — one for |
| 463 | `get` and one for `create` — that the Worker will proxy to the DO, |
| 464 | then make a private group for the DO with just the two original |
| 465 | endpoints: |
| 466 | |
| 467 | ```typescript |
| 468 | // src/api.ts |
| 469 | const getTaskDO = HttpApiEndpoint.get("getTaskDO", "/do/:id", { |
| 470 | params: TaskParams, |
| 471 | success: Task, |
| 472 | error: TaskNotFound, |
| 473 | }); |
| 474 | |
| 475 | const createTaskDO = HttpApiEndpoint.post("createTaskDO", "/do", { |
| 476 | success: Task, |
| 477 | payload: Schema.Struct({ title: Schema.String }), |
| 478 | }); |
| 479 | |
| 480 | export class TasksGroup extends HttpApiGroup.make("Tasks") |
| 481 | .add(getTask) |
| 482 | .add(createTask) |
| 483 | .add(getTaskDO) |
| 484 | .add(createTaskDO) {} |
| 485 | |
| 486 | export class TasksDOGroup extends HttpApiGroup.make("TasksDO") |
| 487 | .add(getTask) |
| 488 | .add(createTask) {} |
| 489 | |
| 490 | export class TaskDOApi extends HttpApi.make("TaskDOApi").add(TasksDOGroup) {} |
| 491 | ``` |
| 492 | |
| 493 | ### Implement the DO |
| 494 | |
| 495 | The DO's constructor returns `{ fetch }` — an `HttpEffect` produced exactly |
| 496 | the same way the Worker produces one, just scoped to its own |
| 497 | `TaskDOApi`. Storage is the DO's transactional `state.storage` |
| 498 | instead of R2. |
| 499 | |
| 500 | ```typescript |
| 501 | // src/object.ts |
| 502 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 503 | import * as Effect from "effect/Effect"; |
| 504 | import * as Layer from "effect/Layer"; |
| 505 | import * as Path from "effect/Path"; |
| 506 | import * as Etag from "effect/http/Etag"; |
| 507 | import * as HttpPlatform from "effect/http/HttpPlatform"; |
| 508 | import * as HttpRouter from "effect/http/HttpRouter"; |
| 509 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 510 | import { TaskDOApi, decodeTask, encodeTask } from "./api.ts"; |
| 511 | import { Task } from "./task.ts"; |
| 512 | |
| 513 | const HttpPlatformStub = Layer.succeed(HttpPlatform.HttpPlatform, { |
| 514 | fileResponse: () => Effect.die("HttpPlatform.fileResponse not supported"), |
| 515 | fileWebResponse: () => |
| 516 | Effect.die("HttpPlatform.fileWebResponse not supported"), |
| 517 | }); |
| 518 | |
| 519 | export default class TasksObject extends Cloudflare.DurableObject<TasksObject>()( |
| 520 | "TasksObject", |
| 521 | Effect.gen(function* () { |
| 522 | const state = yield* Cloudflare.DurableObjectState; |
| 523 | return Effect.gen(function* () { |
| 524 | const tasksGroup = HttpApiBuilder.group(TaskDOApi, "TasksDO", (handlers) => |
| 525 | handlers |
| 526 | .handle("getTask", ({ params }) => |
| 527 | state.storage.get<Task>(params.id).pipe( |
| 528 | Effect.flatMap(decodeTask), |
| 529 | Effect.orDie, |
| 530 | ), |
| 531 | ) |
| 532 | .handle("createTask", ({ payload }) => { |
| 533 | const task = new Task({ |
| 534 | id: crypto.randomUUID(), |
| 535 | title: payload.title, |
| 536 | completed: false, |
| 537 | }); |
| 538 | return state.storage.put(task.id, encodeTask(task)).pipe(Effect.as(task)); |
| 539 | }), |
| 540 | ); |
| 541 | |
| 542 | return { |
| 543 | fetch: yield* HttpRouter.toHttpEffect( |
| 544 | HttpApiBuilder.layer(TaskDOApi).pipe( |
| 545 | Layer.provide(tasksGroup), |
| 546 | Layer.provide([Etag.layer, HttpPlatformStub, Path.layer]), |
| 547 | ), |
| 548 | ), |
| 549 | }; |
| 550 | }); |
| 551 | }), |
| 552 | ) {} |
| 553 | ``` |
| 554 | |
| 555 | ### Bridge the DO into a typed client |
| 556 | |
| 557 | Inside the Worker's constructor, `Cloudflare.toHttpClient(stub)` wraps a DO |
| 558 | stub as an `HttpClient`. Hand that client to `HttpApiClient.makeWith` |
| 559 | and you get a fully typed client whose every call is a DO `fetch` |
| 560 | under the hood: |
| 561 | |
| 562 | ```diff lang="typescript" |
| 563 | +import * as HttpApiClient from "effect/http-api/HttpApiClient"; |
| 564 | +import TasksObject, { TaskDOApi } from "./object.ts"; |
| 565 | |
| 566 | Effect.gen(function* () { |
| 567 | const tasks = yield* Cloudflare.R2.ReadWriteBucket(Tasks); |
| 568 | + const tasksDO = yield* TasksObject; |
| 569 | + |
| 570 | + const getTaskDOClient = (id: string = "default") => |
| 571 | + HttpApiClient.makeWith(TaskDOApi, { |
| 572 | + baseUrl: "http://localhost", |
| 573 | + httpClient: Cloudflare.toHttpClient(tasksDO.getByName(id)), |
| 574 | + }); |
| 575 | ``` |
| 576 | |
| 577 | The `baseUrl` is irrelevant — requests never leave the isolate; the |
| 578 | `httpClient` short-circuits straight to `tasksDO.getByName(id).fetch`. |
| 579 | |
| 580 | ### Wire the proxy handlers |
| 581 | |
| 582 | The Worker's two new handlers just construct a per-request DO client |
| 583 | and forward the typed call. Errors from the DO already match the |
| 584 | public endpoint's error schema, so there's no remapping: |
| 585 | |
| 586 | ```diff lang="typescript" |
| 587 | const tasksGroup = HttpApiBuilder.group(TaskApi, "Tasks", (handlers) => |
| 588 | handlers |
| 589 | .handle("getTask", /* ... R2 implementation ... */) |
| 590 | .handle("createTask", /* ... R2 implementation ... */) |
| 591 | + .handle("getTaskDO", ({ params }) => |
| 592 | + getTaskDOClient().pipe( |
| 593 | + Effect.flatMap((client) => client.TasksDO.getTask({ params })), |
| 594 | + ), |
| 595 | + ) |
| 596 | + .handle("createTaskDO", ({ payload }) => |
| 597 | + getTaskDOClient().pipe( |
| 598 | + Effect.flatMap((client) => client.TasksDO.createTask({ payload })), |
| 599 | + ), |
| 600 | + ), |
| 601 | ); |
| 602 | ``` |
| 603 | |
| 604 | `GET /do/:id` and `POST /do` now hit the DO; `GET /:id` and `POST /` |
| 605 | still hit R2. Same `TaskApi`, same client, two different storage |
| 606 | backends behind the scenes. |
| 607 | |
| 608 | ## Recap |
| 609 | |
| 610 | - The **schema** (`Task`, `TaskNotFound`) and the **API spec** |
| 611 | (`TaskApi`) live outside the Worker — they're pure descriptions. |
| 612 | - The **handlers** are constructed inside the Worker's Construction phase |
| 613 | closure. We build a `Layer` with `HttpApiBuilder.group` but never |
| 614 | `yield*` the running server — that only makes sense per-request. |
| 615 | - The Worker's surface is `{ fetch }`, where `fetch` is an |
| 616 | `HttpEffect` yielded once from `HttpRouter.toHttpEffect`. |
| 617 | - The same `TaskApi` value drives a fully typed client via |
| 618 | `HttpApiClient.make` — no codegen, no string URLs. |
| 619 | |
| 620 | ## Where next |
| 621 | |
| 622 | - [Effect HTTP](/apis/effect-http) — the concept home for this modality. |
| 623 | - [RPC](/apis) — how to choose between Schemaless RPC, Effect RPC, and |
| 624 | Effect HTTP. |
| 625 | - [Effect RPC on Workers](/cloudflare/apis/effect-rpc) — the |
| 626 | schema-first RPC shape for Effect/TypeScript consumers. |
| 627 | - [Schemaless RPC](/cloudflare/compute/workers#call-another-worker) — the |
| 628 | default for internal Worker-to-Worker calls. |