← the tour · concept
concept ix
On Cloudflare: two planes, one gate
The blessed deploy is a single Node node. Cloudflare is for convenience — and it maps orion's coordination boundary onto Durable Objects cleanly: one node = one DO.
orion runs on a single Node process with a single SQLite file. That is the default, the simple path, and the one most apps never need to leave. Cloudflare enters not as a requirement but as a platform that maps onto orion's shape: the coordination boundary is already one node, and on Cloudflare one node is one Durable Object. Nothing in the architecture changes — only what runs the loop.
The split: control plane and data plane
The multi-tenant Cloudflare deploy uses exactly two kinds of Durable Object. The Directory DO is the control plane: it holds the apex home page, cookie sessions, tenant-scoped RBAC, and the gate — the single endpoint the Worker consults before forwarding any data request. Each tenant lives in its own Workspace DO: the data plane, one SQLite file per tenant, with no query path to another tenant's rows.
The gate returns a verdict before anything else moves
Binding a Workspace DO to a Worker is mechanism; membership is policy, and it lives
in the Directory DO. When a request arrives for /w/acme/todos, the
Worker does not go to the Workspace DO. It goes to the Directory DO's
/_gate/acme endpoint first, forwarding the identity from the session.
The gate runs the full RBAC check and returns a verdict — member and role, or a 403.
Only on a 200 does the Worker forward to the Workspace DO, with the verdict as
trusted headers. A non-member is refused before any Workspace DO is contacted.
// the Worker: gate-first, then forward — or 403 at the edge
const verdict = await directoryStub(env).fetch(
new Request(`https://directory/_gate/${ws}`, { headers: authHeaders(request) }),
)
if (verdict.status !== 200) {
return new Response(await verdict.text(), { status: verdict.status })
}
// member → forward with the verdict as trusted headers
const wsId = env.WORKSPACE.idFromName(ws)
return env.WORKSPACE.get(wsId).fetch(noEncoding(request, {
"x-account": account ?? "",
"x-role": role,
"x-can-delete": String(canDelete),
"x-grants": grants.join(","),
}))
The gate is not middleware. It is a real request to the control-plane Durable Object — a synchronous check with a binary verdict — and nothing downstream executes until that verdict arrives. The Workspace DO never sees a stranger.
The loop is unchanged inside the DO
Inside a Workspace DO, orion's loop runs identically to Node. The
Store seam binds to ctx.storage.sql, which is synchronous —
the same no-await render path the whole framework is built around.
The in-process Bus works unchanged: a single-threaded DO holds
every open SSE stream for its tenant, so publish("todos") fans out
to every watcher the same way it does on a Node box. The same feature slices
that run in Node run here; only the composition root changes.
This is the deepest reason the Durable Object model fits orion: orion's coordination boundary is already one node — and on Cloudflare, one node is one DO. The bus never needs an external pub/sub, because the coordinator is already singular by construction.
Trade-offs — be honest about them
The Cloudflare deploy is a real option, not a caveat-free one. Three limits worth knowing before you commit:
- SSE works, but a DO holding it can't hibernate. Only the WebSocket Hibernation API lets a Durable Object evict from memory while a connection stays open; an HTTP SSE stream has no equivalent, so a DO that holds the SSE stays resident and duration-billed while it's open, even idle. For mostly-active sessions that's fine.
- DO SQLite is ~10 GB per tenant. The same "SQLite scales further than you think" logic applies — but it is a ceiling. A tenant that outgrows one DO is a different problem from one that outgrows one Node box, and the honest answer is that the migration path is not simple. Plan your tenant data budget early if you expect large per-tenant data volumes.
- D1 is the wrong store seam. orion's render path is synchronous — views
call
todos.all(ctx.store)with noawait. D1 is async-only. Plugging D1 into theStoreseam would force every view, model, and command toawait, which is a different framework. DO SQLite is synchronous and is the only Cloudflare-native store that fits. The Store stays synchronous because that constraint is load-bearing.
Keeping SSE cheap: the Worker–DO broker planned
The SSE-residency cost above has a clean fix that keeps Datastar's SSE end to end: a broker. Split who holds what. The Worker — stateless, and not billed for idle wall-time the way a resident DO is — holds the long-lived SSE to the browser. The Worker then talks to the Workspace DO over a WebSocket, which can hibernate. On each event the DO wakes, renders the fragment, pushes it up the socket to the Worker (which relays it down the SSE to the user), and goes back to sleep after its idle timeout.
Planned — the deployed demo keeps it simple (the DO holds the SSE directly), which is exactly why an idle workspace DO stays resident. The broker is the production cost-optimization; it changes only where the stream is held, not the loop.
The architecture console
The running deploy exposes a /console that X-rays the whole design
live: both planes, every Workspace DO's real idFromName id and
live todo count (a genuine cross-tenant fan-out — N DO round-trips), the RBAC
role-to-permission map, a persona switcher, and a live SQL feed showing the
actual store calls as they land across Durable Objects. Switch to
alice·Personal, who owns home but is not in
acme, then probe acme — the gate returns a 403 toast before
any Workspace DO is contacted. "No cross-tenant leak by construction," clickable.
The todos page itself shows the x-ray footer: which physical DO served it,
the viewer's role, the RBAC grant strings, and the live permission check result.
A member with the member role sees no delete control; the Workspace
DO's command refuses it regardless. The button is a courtesy; the gate is the law.
It is the same architecture described on this page — made visible, running on Cloudflare, open for inspection.