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.
Four layers, each usable alone
Section titled “Four layers, each usable alone” 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
effecttarget. 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 asStreams, 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.shbuilds 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 theinfracommand, 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.
Two halves of infra
Section titled “Two halves of infra”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.
The journey of infra ship
Section titled “The journey of infra ship”Here’s what happens, in order, when you ship. Each step can stop the ship, and everything before it is undone.
1. Load the app, without running it
Section titled “1. Load the app, without running it”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.
2. Check it
Section titled “2. Check it”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.
3. Take the lock and read the account
Section titled “3. Take the lock and read the 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.
4. Order the Workers
Section titled “4. Order the Workers”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.
5. Ship each Worker
Section titled “5. Ship each Worker”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:
- Provision what the bindings need: the KV namespace, the D1 database, the bucket, the queue. Each made only if it’s missing.
- Prepare: apply the D1 migrations the database hasn’t had, each with its record, in one batch.
- 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.
- 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.
- 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.
6. Record, and let go
Section titled “6. Record, and let go”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.
If anything fails
Section titled “If anything fails”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, the same wiring locally
Section titled “infra dev, the same wiring locally”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.
Around the code
Section titled “Around the code”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 |
- A tour of the CLI: every
infracommand, in the order you’ll meet them. - Plan and ship: the rollout, in detail.