Provision + deploy a Suluk app to Cloudflare from one API call — no wrangler CLI.
Part of Suluk — one typed OpenAPI v4 contract projecting into every full-stack layer.
CANDIDATE tooling — not official OpenAPI. Suluk is a single-contributor candidate for OpenAPI Specification v4.0 ("Moonwalk"), unaffiliated with the OpenAPI Initiative and unable to ratify anything on the SIG's behalf.
bun add @suluk/cloudflare
A typed wrapper over the Cloudflare REST API that ships a Worker + its dependencies in dependency order, with no wrangler binary and no wrangler.toml.
CloudflareClient — a thin, typed client over https://api.cloudflare.com/client/v4 with Bearer auth, the standard { success, errors, result } envelope unwrapped, and a CloudflareError that carries the API's own error codes (so a failed deploy says why). fetch is injectable, so the whole library is unit-testable without a network.provisionD1 / provisionKvNamespace / provisionR2Bucket are create-or-get (re-running never errors with "already exists"), applyMigrations keeps a _suluk_migrations ledger so each migration runs at most once (and baselines a DB migrated before the ledger existed), and putSecrets skips empty values.uploadAssets builds the manifest, opens an upload session, and pushes only the buckets the API asks for (so a redeploy only sends changed files). _headers / _redirects are routed into the worker's assets.config as rules, not uploaded as serveable blobs.deploy() — provision → migrate → upload assets → deploy the module worker (with bindings + vars) → push secrets → set cron triggers, in the order where each step's output feeds the next. keep_bindings preserves secrets across redeploys, so you set them once.durableObjects: [{ binding, className }] and deploy() binds each as a durable_object_namespace and creates the same-script classes via an inline script migration (new_sqlite_classes, the Agents SDK + free-plan backend). The migration rides the same script-upload PUT (no versions API) and uses the API field new_tag (≠ wrangler's tag); it's omitted entirely when there's nothing to create (an empty block can reset DO state). nodejs_compat is auto-injected for any DO deploy so the worker can't ship missing the Agents-SDK runtime flag. Safe for first-deploy + redeploy; additive evolution (oldTag/new_tag to add a class later) is plumbed but caller-driven.kvRateLimitStore — the production, KV-backed RateLimitStore for @suluk/hono's enforceRateLimit (a fixed-window counter that fails open on a KV blip). Structurally typed, so it plugs straight in with no @suluk/hono dependency.Reach for @suluk/cloudflare when you want to deploy a Suluk app (or any static/worker site) to Cloudflare programmatically — from a deploy.ts script or CI — rather than via the wrangler CLI. It is the executing adapter: it holds your token and actually calls the API.
@suluk/deploy — @suluk/deploy is pure planning behind a swappable provider interface; it computes the files + ordered steps to ship but never executes anything or touches credentials. @suluk/cloudflare is the concrete Cloudflare doer. Use @suluk/deploy when you want a provider-agnostic plan; use this when you're committing to Cloudflare and want it to run.deployWith builds a client from your token/account and runs a full deploy. Pass it a DeployPlan of bytes, not paths — the disk-reading lives in your app script (see Boundary).
import { deployWith, type AssetFile } from "@suluk/cloudflare";
import { readFileSync } from "node:fs";
const assets: AssetFile[] = [
{ path: "/index.html", bytes: new Uint8Array(readFileSync("dist/index.html")), contentType: "text/html" },
// …walk your dist/ dir and map each file to { path, bytes, contentType }
];
const res = await deployWith(
{ apiToken: process.env.CLOUDFLARE_API_TOKEN!, accountId: process.env.CLOUDFLARE_ACCOUNT_ID },
{
scriptName: "saasuluk",
module: readFileSync("worker/dist/worker.js", "utf8"), // the bundled ES module
compatibilityDate: "2026-06-01",
compatibilityFlags: ["nodejs_compat"],
d1: { binding: "DB", databaseName: "saasuluk-db", migrations: [{ name: "0000_init.sql", sql: "CREATE TABLE t (id INTEGER);" }] },
kv: [{ binding: "RATE_LIMIT_KV", title: "saasuluk-ratelimit" }],
r2: [{ binding: "MEDIA", bucketName: "saasuluk-media" }],
assets,
assetsConfig: { html_handling: "auto-trailing-slash" },
vars: { STRIPE_METER_EVENT_NAME: "saasuluk_cost" }, // plain-text bindings
secrets: { BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET }, // encrypted; empty/undefined skipped
crons: ["0 * * * *"],
observability: true,
},
(msg) => console.log(" " + msg), // optional DeployLog — narrates each step
);
console.log(`Deployed "${res.scriptName}" to ${res.accountId} — D1 ${res.d1?.id}, ${res.assetsUploaded} assets, secrets: ${res.secretsSet.join(", ")}`);
The token must be Account-scoped: Workers Scripts (Edit), D1 (Edit) (+ KV/R2 Edit if used), and Account Settings (Read). Omit accountId and it resolves to the token's first account.
deploy(cf, plan, log?) is the same orchestration over an existing CloudflareClient if you already hold one.
Every step deploy() runs is also exported. Use them for one-off work the plan doesn't cover (e.g. seeding a DB after migrations, or attaching a route via raw request):
import { CloudflareClient, queryD1, provisionKvNamespace } from "@suluk/cloudflare";
const cf = new CloudflareClient({ apiToken: process.env.CLOUDFLARE_API_TOKEN! });
const db = await provisionKvNamespace(cf, "my-cache"); // create-or-get, idempotent
await queryD1(cf, databaseId, "INSERT OR REPLACE INTO setting (k, v) VALUES ('seeded', '1');");
// anything not in the plan yet (routes, custom domains, …) → the raw typed request:
await cf.request("PUT", `/accounts/${await cf.resolveAccountId()}/workers/scripts/saasuluk/routes`, {
json: { pattern: "example.com/*", zone_name: "example.com" },
});
kvRateLimitStore is the production store for @suluk/hono's enforceRateLimit. The KV binding isn't available at module init on Workers, so pass a lazy getter; it falls open to an in-memory fallback when KV is absent or errors.
import { kvRateLimitStore } from "@suluk/cloudflare";
import { enforceRateLimit } from "@suluk/hono";
let kv: KVNamespace | undefined;
const store = kvRateLimitStore(() => kv); // captures the binding on first request
app.use("*", async (c, next) => { if (!kv && c.env.RATE_LIMIT_KV) kv = c.env.RATE_LIMIT_KV; return next(); });
app.use("*", enforceRateLimit({ store, /* …operationOf, rateLimitOf, keyOf… */ }));
memoryRateLimitStore() is the dev-only / fail-open fallback — per-isolate, not coordinated across Workers instances.
| Export | What it does |
|---|---|
deploy(cf, plan, log?) / deployWith(opts, plan, log?) |
the full orchestration over a DeployPlan; returns a DeployResult |
CloudflareClient |
typed REST client; request(method, path, opts) unwraps result, resolveAccountId() caches the account |
CloudflareError |
thrown on success:false/non-2xx, carrying status + the API's errors[] |
provisionD1 / provisionKvNamespace / provisionR2Bucket |
idempotent create-or-get for D1 / KV / R2 |
applyMigrations / queryD1 |
ledgered, run-once D1 migrations / raw D1 SQL |
putSecret / putSecrets |
encrypted Worker secrets (putSecrets skips empty values, returns the names set) |
uploadAssets / assetHash / extractAssetRuleFiles |
the Workers static-assets upload flow |
deployWorker / putCronTriggers |
upload a module worker (incl. DO bindings + inline migrations) / set its cron schedule |
DeployPlan.durableObjects / DurableObjectBinding / WorkerMigration |
bind + migrate SQLite-backed Durable Object agents (Cloudflare Agents SDK) |
kvRateLimitStore / memoryRateLimitStore |
KV-backed (prod) / in-memory (dev) RateLimitStore for @suluk/hono |
This package executes — it holds your token and calls Cloudflare. But it is pure over its inputs: deploy() takes a DeployPlan of bytes, not paths (the module source, the AssetFile[], the migration SQL), so the whole library is unit-testable without disk or network. The disk-reading wrapper is the app's seam — walking dist/, reading the worker bundle and migration files lives in your scripts/deploy.ts, not in here (see saasuluk's scripts/deploy.ts). Inject the bytes; inject the fetch.
What stays out of the plan today: routes / custom domains aren't modeled — attach them via CloudflareClient.request (the typed escape hatch). If that pattern recurs, that's the candidate to fold into DeployPlan.
Apache-2.0.