Skip to content

How it fits together

How it thinks is the idea. This page is the machinery: the pieces, how they stack, and the journey your app takes on its way to Cloudflare. You don’t need any of it to use forge-effect, but it’s nice to know what’s under the floor.

your app (app.ts)
│ declares Workers and resources in Effect
▼
┌──────────────────────┐
│ @forge-effect/infra │ apps: plan, ship, destroy; checks; dev; state
└──────────┬───────────┘
│ ships each Worker with
▼
┌──────────────────────┐ ┌─────────────────────────┐
│ @forge-effect/ship │◄──│ @forge-effect/deployer │ ship's core, run
└──────────┬───────────┘ └─────────────────────────┘ inside a Worker
│ talks to Cloudflare through
▼
┌───────────────────────────┐
│ @forge-effect/cloudflare │ Cloudflare's whole API, as Effect
└──────────┬────────────────┘
│ generated by
▼
forge --target effect (a fork of Cloudflare's SDK generator)

From the bottom up:

  • forge, with an effect target. forge is Cloudflare’s SDK generator. Our fork adds --target effect, which turns an OpenAPI document into typed Effect clients: a typed error per declared response, pagination as Streams, auth, retries, test layers.
  • @forge-effect/cloudflare. forge run on Cloudflare’s own OpenAPI document: an Effect client for every Cloudflare API, around 1,300 groups of them. It’s generated, not written, and not committed: scripts/generate.sh builds it from a pinned commit of the fork.
  • @forge-effect/ship. A deploy engine for one Worker, with no Wrangler inside. It provisions what the Worker’s bindings need, uploads a version, rolls it out a step at a time behind smoke tests that check which version answered, and undoes everything on failure. Its core uses no Node APIs, so it runs in Node or in a Worker. It reads Cloudflare’s own formats (cloudflare.config.ts, the Build Output), so it works on apps that have never heard of infra: see Using ship on its own.
  • @forge-effect/deployer. A Worker that runs ship’s core, so deploys can happen inside Cloudflare.
  • @forge-effect/infra. Where you live. Resources that are their own clients, Workers the compiler checks, contracts between Workers, and the infra command, which plans and ships whole apps with ship.

Nothing depends upward: ship doesn’t know infra exists, and the Cloudflare client doesn’t know about either.

infra is really two packages in one, and the line between them matters:

Runs where Holds
@forge-effect/infra in your Worker resources and their clients, Worker.make, contracts, Durable Objects, Workflows, the stand-ins for tests
@forge-effect/infra/deploy in Node, never bundled loading apps, checks, planning, shipping, state, infra dev

Your Worker’s bundle carries only the first. The second reads your app and talks to Cloudflare.

Here’s what happens, in order, when you ship. Each step can stop the ship, and everything before it is undone.

infra bundles your app.ts with esbuild for Node (with workerd’s cloudflare:* modules swapped for stubs) and imports it. Your module’s top-level code runs, which builds the App.make(...) value: plain data describing every Worker, every resource, every contract. No handler runs. That’s why app modules only declare: Workers can’t do I/O at the top level anyway, so a module that loads in workerd loads here too.

The same checks as infra check, offline:

  • Types: TypeScript on your project. A missing binding, a contract method left out, a handler failing with an undeclared error, each with its code.
  • Declarations: names over Cloudflare’s limits, crons Cloudflare won’t take, a queue nobody consumes, a migration that drops data.
  • Bundles: each Worker bundled as it will deploy, its size against Cloudflare’s limits.

An error stops the ship here, before anything touches your account.

infra keeps its state in a D1 database in your account, forge-effect-state. It takes a lock for this app and environment (so two ships can’t race), then reads what exists: which resources are there, which are this app’s, which belong to someone else (name-taken), what other apps your app calls, and what each Worker is running now.

A Worker deploys after the Workers it calls (a service binding needs its target to exist), after the Worker hosting the Durable Objects or Workflows it uses, and after its Tail Worker. A cycle can’t be ordered, so it’s refused, back at step 2.

For each Worker, infra generates an entry module: your app module, with that Worker’s handlers as its default export, plus the classes Cloudflare needs (a WorkerEntrypoint for a contract, one per Durable Object, one per Workflow). esbuild bundles it for workerd. Then ship takes over:

  1. Provision what the bindings need: the KV namespace, the D1 database, the bucket, the queue. Each made only if it’s missing.
  2. Prepare: apply the D1 migrations the database hasn’t had, each with its record, in one batch.
  3. Upload a version with no traffic. In front of your code, ship puts a small probe, which answers a random, secret path with the version’s id.
  4. Roll out: 10% of traffic, then 50%, then 100% (a Worker’s first ship goes live at once). At each step, smoke tests call the Worker and ask the probe which version answered, so a stale cache can’t fool them.
  5. Finish: its Workflows registered, settings that apply once a version is live, cron triggers, queue consumers.

Then infra does what’s beyond one Worker: attaches routes and custom domains, and sets bucket notifications.

What was made is recorded in the state, by its Cloudflare id, so the next ship binds it by id and never makes it twice. A resource you stopped declaring is unbound and kept, marked an orphan, until infra prune. The ship goes into the history, and the lock is released.

Every step above runs inside an Effect scope. If one fails, or you press Ctrl-C, the scope closes and runs each step’s undo in reverse: traffic back to the old version, new resources deleted, the lock released. Data is never deleted to undo a ship. The one thing that can’t undo is a process killed outright (kill -9): its lock stays, and the next ship takes it over after 30 minutes.

infra dev loads the app the same way, generates the same entry modules, and hands them to Miniflare, which runs them in workerd on your machine: KV, D1, R2 and queues local and kept on disk in .infra/dev/, service bindings between your Workers, Durable Objects in their host, migrations applied before it answers. So “works in dev” means the wiring is right too, not only the code. See Local development.

The repository also holds:

docs/story.md why this exists, how it’s built, and what reality taught along the way
docs/design.md the decision record: every design choice and why
docs/workers-platform.md which Cloudflare features are covered, and how
HANDOFF.md where things stand and what’s next
packages/docs this guide, and the infra app that ships it
sdk/, example/ the first prototype: the effect target on a small pets API