Cloudflare bindings
Every Cloudflare resource Cusp binds to and what each one is for
Cusp runs as a single Worker that owns every binding. The public render handler, the admin API, the headless API, and the static-assets fall-through share one deployment and reach Cloudflare resources through one env object. No service splits at the data layer: one D1 database, one R2 bucket, one KV namespace. The Deploy to Cloudflare button provisions all of it from the cloudflare.bindings block in package.json.
Storage
- DB — the D1 database. All structured data lives here: users, sessions, settings, content collections, field and block definitions, the universal entity_versions log, and the FTS5 search index. Cusp talks to D1 through raw prepared statements wrapped by a chunked-write repository, never an ORM.
- BUCKET — the single R2 bucket, organised by prefix. /documents holds blocks spillover, /media holds uploads, /rendered holds prerendered HTML, /themes and /plugins hold installed packages, /versions holds version-history spillover, /backups holds nightly backups, and /site holds the generated sitemap, feeds, and robots.txt.
- KV — the KV namespace. Caches site settings, navigation fragments, sitemap blobs, the schema cache, and computed aggregates that tolerate eventual consistency. It also backs the session-token fast path so the admin does not pay the Argon2id cost on every request.
- ASSETS — the Workers Static Assets binding. Serves the admin SPA, which Astro builds into dist/admin/ at deploy time. The admin code never enters the Worker bundle; it is served as static files and talks to the admin API over JSON.
Coordination — Durable Objects
- DOC_SESSION — the DocumentSession Durable Object namespace. One instance per document, addressed by env.DOC_SESSION.idFromName(documentId). While editors are connected over WebSockets it is the source of truth for the document, serialising every edit through the single-threaded actor. It flushes to D1 after idle or a batch of operations and uses WebSocket Hibernation for cheap idle wakeups. There is no CRDT and no Yjs.
- RATE_LIMIT — the RateLimit Durable Object namespace. A sliding-window limiter keyed by an arbitrary string, partitioned by purpose: login and password-reset by email and IP, per-token quota on the headless API, anonymous-IP buckets, and per-actor keys for the customise live preview and install kickoff. Strongly-consistent counters in transactional storage that survive Worker restarts, where KV cannot. Exceeding a quota returns 429 with a Retry-After header.
Async work — Workflows
Every multi-step async job is a Cloudflare Workflow. Each step is independently retryable, and periodic work runs from long-lived self-scheduling instances that wait with step.sleepUntil — there are no Cron triggers and no queue polling. The Worker instantiates a Workflow via env.PUBLISH.create({ params }) and never awaits its completion in the request path.
- PUBLISH — the publish pipeline. In order: verify the document is still published, render it to R2, re-render the listings it appears in, index it for search, notify subscribed plugins, purge the affected surrogate keys, then regenerate the aggregates (sitemap, feeds, robots, home) through a debounced step that issues a second conditional purge. Runs every time you publish.
- UNPUBLISH — the symmetric counterpart: drains the R2 prerender, regenerates affected listings and feeds, and issues surrogate-key purges.
- SCHEDULE — sleeps until a scheduled publish time with step.sleepUntil, then runs the publish pipeline. A sleeping instance costs effectively nothing.
- IMPORT — restores a site from a backup tarball, the inverse of EXPORT. It validates the manifest, then replays each table in chunked, cursor-resumable steps.
- MIGRATE — rewrites every document's data when an Administrator changes a field's type on a populated collection. Per-document checkpointing makes a transient infrastructure blip safe to retry.
- EXPORT — produces nightly backups (a per-table D1 JSONL dump, a media inventory, and a manifest, sealed into a tarball) and writes them to the bucket. Runs daily at 03:00 UTC from a long-lived self-scheduling instance bootstrapped at install.
- INSTALL — downloads, verifies the signature of, and installs themes and plugins.
- RESTORE — site-wide time-travel restore. Walks every versioned entity and mints a new version equal to its state at a target timestamp (a two-pass forward-mint then tombstone-delete of anything created since). The current state stays on the trail as the version just before the restore, so the action is itself reversible.
- SEED — seeds the canonical example site, The Royal Society of Pheasant Patissiers. Triggered from the first-run wizard, the admin's Seed sample content action (POST /api/admin/example-site/seed), or the contributor CLI. Idempotent.
- PRUNE — prunes entity_versions and revisions past per-entity-type caps daily at 04:00 UTC, one hour after EXPORT so a backup captures the pre-prune state. A long-lived instance, created unconditionally at install.
- SWEEP_MEDIA — hard-deletes soft-deleted media past the 30-day retention window and cleans up orphaned R2 objects. Runs daily at 02:00 UTC.
- REBUILD_SEARCH — rebuilds the FTS5 search index from the documents table. Kicked once at setup so a fresh install, or one that imported content before setup finished, has a populated index; that first-boot enqueue no-ops when the index is already populated. The manual Health-page rebuild passes force and repopulates unconditionally.
Plugin sandboxing
PLUGIN_LOADER is the Dynamic Worker Loader binding. The host Worker uses it to spin up sandboxed sub-isolates for theme templates today, and for plugins as the plugin system lands. The loader factory sets bindings to an empty object and globalOutbound to null, so sandboxed code cannot reach the host's bindings or the network unless its manifest declares the capability. Per-plugin bindings are constructed at load time from the manifest, not declared in wrangler.jsonc. This binding requires the Dynamic Worker Loader account flag.
EMAIL is the Cloudflare Email Service binding (send_email in wrangler.jsonc). It sends password-reset messages, the setup-wizard verification ping, and mail sent on behalf of plugins that declare the email capability, directly, with no external provider and no SMTP credentials. Email is a built-in core capability, not a plugin — the send path is src/shared/email/send.ts. After deploy, verify your sending domain in Cloudflare Email Routing so messages are delivered.
The From address is noreply@${CUSP_EMAIL_DOMAIN}, set from the CUSP_EMAIL_DOMAIN deployment variable. When that variable is unset, empty, or whitespace, the From address falls back to noreply@cusp.local — the local-dev default, where the binding is a noop. Set CUSP_EMAIL_DOMAIN to your verified sending domain in production so outbound mail is delivered.
Upload limits
Upload size and MIME enforcement is a Worker-boundary check, never a client-side one. The admin SPA reads GET /api/admin/media/limits to render informational hints, but the load-bearing checks live on POST /api/admin/media: a Content-Length pre-check rejects oversized bodies before they are read, and the uploaded bytes are validated against an image magic-byte signature after the read.
GET /api/admin/media/limits returns { maxBytes, allowedMimes, transformsEnabled }. Today maxBytes is 25,000,000 (25 MB) and allowedMimes is image/png, image/jpeg, image/webp, image/gif, and image/avif; transformsEnabled mirrors the images.transform_enabled setting so the media drawer knows whether to request resized previews. Files ending in .html, .svg, or .js are never accepted from the uploads path.
How bindings are reached in code
Inside a Hono handler the bindings hang off the request context as c.env; inside a Durable Object or a Workflow they arrive on the constructor or run context. Optional values passed to a D1 .bind() are coerced to null with ?? null, because D1 rejects undefined.
const app = new Hono<{ Bindings: Env }>();
app.post("/api/admin/documents/:id/publish", async (c) => {
const { id } = c.req.param();
await c.env.PUBLISH.create({ params: { documentId: id } });
return c.json({ status: "queued" }, 202);
});