@bounda-dev/cloudflare runs a Bounda app on Cloudflare: a Worker in front and a
Durable Object per tenant, each holding
its events, its read models and its scheduled work in its own SQLite. There is nothing else to
deploy or to keep running.
npm create bounda@latest my-app -- --runtime cloudflareOr deploy the same project to your account without cloning anything:
The button forks bounda-cloudflare-template into your GitHub account, creates the Durable Object and deploys it; every push to the fork deploys again.
That gives you the order app of Getting started with three extra files:
import { cloudflare } from "@bounda-dev/cloudflare";import { defineConfig } from "@bounda-dev/core/config";
export default defineConfig({ storage: cloudflare() });import { createBoundaObject, createWorker } from "@bounda-dev/cloudflare";import { registry } from "../.bounda/registry.ts";import config from "../bounda.config.ts";
export const Store = createBoundaObject({ registry, config });export default createWorker({ config, tenantOf: (request) => request.headers.get("x-bounda-tenant") ?? "default",});{ "name": "my-app", "main": "src/worker.ts", "assets": { "directory": "./public" }, "compatibility_date": "2026-09-21", "durable_objects": { "bindings": [{ "name": "STORE", "class_name": "Store" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Store"] }], "observability": { "enabled": true }, "upload_source_maps": true}public/index.html is a page that places orders and lists them through the API, served as a
static asset. npm run dev starts wrangler dev on http://localhost:8787; npm run deploy
deploys to your account. A React Router app runs on Cloudflare the same way, with the object
behind its loaders and actions: see Bounda with React Router,
or start one with
npm create bounda@latest my-app -- --runtime cloudflare --framework react-router.
How it runs
Section titled “How it runs”One Durable Object is one store: the same event store and global stream, subscribers and checkpoints as on SQLite or PostgreSQL, in the object’s SQLite. The SQL is literally the same code as the SQLite adapter. What changes is who does the background work, because a Durable Object has no loop running between requests:
- A command stores its events and brings the read models that project them up to date before
it answers, so the query that follows already sees it. That is the default; with
consistency: "eventual"inconnectorcreateWorker, a command answers once its events are stored and the alarm projects them right after (the host decides read-your-writes). - Policies, processes, scheduled commands and retries run in the object’s alarm, right after the command answers, in their own event. A policy that calls a slow service never slows the command down.
- The object arms its own alarm: at once when work is left or new events arrived, after a retry interval when a retry back-off is holding events, at the due time of the next scheduled command or process timeout, whichever comes first. Nothing is armed when nothing is pending.
- The alarm never throws. Cloudflare retries a failing alarm six times and then drops it; the object catches every failure, logs it and arms itself again.
createBoundaObject accepts passesPerAlarm (50 by default): how many rounds of work one alarm
does before it yields and wakes itself again.
Ports and env
Section titled “Ports and env”A port implementation that exports create receives the object’s env: the Worker’s
bindings and variables, typed as Cloudflare.Env from what wrangler types writes. That is how
a queue, a KV namespace, a service binding or a secret reaches it. create runs once per object,
when it starts.
import type { CreateImplementation } from "@bounda-dev/core";import type { Notifier } from "../../notifier.ts";
export const create: CreateImplementation<Notifier> = ({ env }) => (message) => env.NOTIFICATIONS.send(message);create also receives the object’s name as tenant, the one idFromName addressed it by, so an
implementation can serve each tenant with its own account or key
(an account per tenant). An object
reached through an id without a name, from newUniqueId or idFromString, has no tenant; the
Worker of createWorker and the React Router host always address it by name.
bounda.config.ts can choose an implementation from the same variables, since env from
cloudflare:workers is readable when the Worker imports it:
import { env } from "cloudflare:workers";
export default defineConfig({ storage: cloudflare(), ports: { order: { notifier: env.NOTIFIER === "memory" ? "memory" : "queue" } },});Since an empty object has none of those bindings, createApp and createTestApp require env
in a Cloudflare project whose registry has an implementation with create, instead of
defaulting to {}.
A Durable Object never stops its app, so [Symbol.asyncDispose] on what create returns only
runs under createTestApp or an app you stop yourself.
One object per tenant
Section titled “One object per tenant”createWorker addresses the object by the tenant tenantOf names for each request: in the
project above, the x-bounda-tenant header, default without it. Each tenant is its own object,
with its own events and read models, and nothing is shared between them. That is also how a
Cloudflare app scales: one object handles in the order of a thousand requests a second, and
How Bounda runs explains why the way out is more stores, not a split
global stream.
The HTTP API
Section titled “The HTTP API”createWorker({ config, tenantOf }) serves one app as JSON, through the Durable Object binding
cloudflare() names: STORE unless cloudflare({ binding }) says otherwise.
| Request | Answer |
|---|---|
POST /commands/<name> with the payload as the body |
The dispatch result, once the read models reflect it, or once its events are stored with createWorker({ config, tenantOf, consistency: "eventual" }). ?delay=10m schedules it |
POST /queries/<name> with the payload as the body |
The query’s result |
Refusals come back as { "error": { "code", "message" } } with the statuses in
errors over HTTP; anything else is a 500 whose message goes to the
logs, not to the caller.
It has no authentication and no operator endpoint, on purpose: it is a starting point. An
app with users writes its own fetch and talks to a store with connect:
import { connect } from "@bounda-dev/cloudflare";
export default { async fetch(request, env) { const tenant = await tenantOfUser(request); // your authentication const store = connect(env.STORE.get(env.STORE.idFromName(tenant))); await store.commands.placeOrder({ orderId, customerId, total }); return Response.json(await store.queries.listOrders({ customerId })); },} satisfies ExportedHandler<Env>;connect(stub) gives a BoundaClient, the type @bounda-dev/core gives to what a request sees
of an app wherever it runs: the same commands and queries as app.commands and
app.queries, typed from your modules, plus getLag(), events, deadLetters and
rebuildReadModel.
Its commands resolve once the read models reflect them; connect(stub, { consistency: "eventual" })
resolves them once their events are stored, for a request that reads nothing after it. A refusal comes
back as an Error with the same name, message, code and, for validation, issues, whatever
the Worker’s compatibility date: the object answers refusals as data and connect throws them
again, because RPC drops an error’s own properties on older dates. Check error.code, not
instanceof. A command’s signal only counts before the call leaves the Worker: RPC cannot
carry it into the object, where the command runs to the end, bounded by
runtime.commands.timeout.
Testing
Section titled “Testing”The tests run inside workerd through
@cloudflare/vitest-plugin,
on the same Vitest as a Node project. tests/orders.test.ts runs the domain on the in-memory
adapter with createTestApp, as in any Bounda project; tests/api.test.ts sends requests to
the Worker with SELF.fetch and reaches the real Durable Object and its SQLite. The adapter’s own
suite runs every storage contract inside workerd too.
Types for the bindings come from wrangler types, which writes worker-configuration.d.ts from
wrangler.jsonc; dev, typecheck and check run it, and tsconfig.json lists that file
instead of @cloudflare/workers-types. There is no prepare script: every script runs
bounda generate itself, and npm install --package-lock-only stays possible without
node_modules.
Limits worth knowing
Section titled “Limits worth knowing”- One object lives in one region. Users far from it pay the latency on every command.
- A projection must not wait on anything but its read model. Each batch runs inside the
object’s storage transaction, and while one is open the runtime holds the object’s other
events back. A projection that awaits a
fetchor a timer can then wait forever for an event queued behind it, and the object stops. Projections that only usetableandclientnever hit this; anything else belongs in a policy, on every adapter. - A rebuild runs in slices.
rebuildReadModelprojects the firsteventsPerRebuildSliceevents (5,000 by default, an option ofcreateBoundaObject) and answersdone: falsewhen the global stream is longer; the object’s alarm runs one slice after another until the rebuilt table takes the live one’s place. Queries read the live table all the while. Each slice is one more request to the object, and a slice that fails is retried after the dispatcher’s poll interval. - Cost. Row writes are what runs out first. A command that inserts one read-model row writes
seven rows, indexes included: four for the event, two for the read-model row, one for the
checkpoint. On the free plan’s 100,000 row writes a day that is about fourteen thousand
commands, when the app has no policies or processes and so no alarm to run after each one.
Each policy or process reaction adds its own writes, and the alarm that runs it is one more
request.
"eventual"consistency saves nothing: in an app without policies or processes each command then arms the alarm, which Cloudflare bills as one more row written, and the alarm that projects is one more request; with them, the alarm runs after each command anyway and projects in the same request. What it buys is a command that answers sooner. On the paid plan the first fifty million row writes a month are included, and a million commands beyond that cost around eight dollars.