@suluk/cloudflare 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@suluk/cloudflare",
3
+ "version": "0.1.0",
4
+ "description": "Butter-smooth, API-driven provisioning + deployment for a Suluk app on Cloudflare — no wrangler CLI. A typed REST client + idempotent provisioners (D1, KV, R2, secrets) + the Workers module-script + static-assets upload flow, orchestrated into one deploy(). The platform that ships itself, shipping itself. CANDIDATE tooling.",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "license": "Apache-2.0",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/MahmoodKhalil57/suluk.git",
12
+ "directory": "tooling/ts/packages/cloudflare"
13
+ },
14
+ "homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
15
+ "bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
16
+ "type": "module",
17
+ "main": "src/index.ts",
18
+ "module": "src/index.ts",
19
+ "types": "src/index.ts",
20
+ "exports": {
21
+ ".": "./src/index.ts"
22
+ },
23
+ "dependencies": {},
24
+ "devDependencies": {
25
+ "@types/bun": "latest"
26
+ },
27
+ "scripts": {
28
+ "test": "bun test",
29
+ "typecheck": "tsc --noEmit -p ."
30
+ }
31
+ }
package/src/assets.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The Workers Static-Assets upload flow: build a manifest (path → {hash,size}), open an upload session, push the
3
+ * file buckets the API asks for, and return the COMPLETION JWT to embed in the worker's `assets.jwt`. The manifest
4
+ * hash is the SHA-256 of the contents (hex). Idempotent: unchanged files aren't re-requested by the session, so a
5
+ * redeploy only uploads what changed.
6
+ */
7
+ import type { CloudflareClient } from "./client";
8
+
9
+ /** One asset: its server path (e.g. "/index.html"), bytes, and content type. */
10
+ export interface AssetFile {
11
+ path: string;
12
+ bytes: Uint8Array;
13
+ contentType: string;
14
+ }
15
+
16
+ async function sha256Hex(bytes: Uint8Array): Promise<string> {
17
+ const digest = await crypto.subtle.digest("SHA-256", bytes as unknown as ArrayBuffer);
18
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
19
+ }
20
+
21
+ /** The Workers-Assets manifest hash: the first 32 hex chars (16 bytes) of the contents' SHA-256. The API rejects
22
+ * the full 64-char digest ("file hash size of 64 is too large"). */
23
+ export async function assetHash(bytes: Uint8Array): Promise<string> {
24
+ return (await sha256Hex(bytes)).slice(0, 32);
25
+ }
26
+
27
+ const toBase64 = (bytes: Uint8Array): string =>
28
+ typeof Buffer !== "undefined" ? Buffer.from(bytes).toString("base64") : btoa(String.fromCharCode(...bytes));
29
+
30
+ export interface UploadSession {
31
+ jwt: string;
32
+ /** the file hashes (grouped into buckets) the API still needs uploaded; empty when everything is cached. */
33
+ buckets?: string[][];
34
+ }
35
+
36
+ /**
37
+ * Upload a set of static assets; returns the completion JWT for the worker metadata, or `null` when there are none.
38
+ * When every file is already cached server-side the session returns no buckets and its own jwt IS the completion token.
39
+ */
40
+ export async function uploadAssets(cf: CloudflareClient, scriptName: string, files: AssetFile[]): Promise<string | null> {
41
+ if (!files.length) return null;
42
+ const acct = await cf.resolveAccountId();
43
+
44
+ const byHash = new Map<string, AssetFile>();
45
+ const manifest: Record<string, { hash: string; size: number }> = {};
46
+ for (const f of files) {
47
+ const hash = await assetHash(f.bytes);
48
+ manifest[f.path] = { hash, size: f.bytes.byteLength };
49
+ byHash.set(hash, f);
50
+ }
51
+
52
+ const session = await cf.request<UploadSession>("POST", `/accounts/${acct}/workers/scripts/${scriptName}/assets-upload-session`, { json: { manifest } });
53
+ let completion = session.jwt;
54
+
55
+ for (const bucket of session.buckets ?? []) {
56
+ const form = new FormData();
57
+ for (const hash of bucket) {
58
+ const f = byHash.get(hash);
59
+ if (!f) continue;
60
+ form.append(hash, new Blob([toBase64(f.bytes)], { type: f.contentType }), hash);
61
+ }
62
+ const r = await cf.request<{ jwt?: string } | null>("POST", `/accounts/${acct}/workers/assets/upload`, { query: { base64: true }, body: form, token: session.jwt });
63
+ if (r?.jwt) completion = r.jwt; // the last successful bucket returns the completion token
64
+ }
65
+ return completion;
66
+ }
package/src/client.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The Cloudflare REST client — a thin, typed wrapper over `https://api.cloudflare.com/client/v4` with Bearer-token
3
+ * auth, the standard `{ success, errors, result }` envelope, and a CloudflareError that surfaces the API's own error
4
+ * codes (so a failed deploy says WHY). `fetch` is injectable so the whole library is unit-testable without a network.
5
+ */
6
+ export interface CloudflareError_t {
7
+ code: number;
8
+ message: string;
9
+ }
10
+
11
+ export class CloudflareError extends Error {
12
+ constructor(
13
+ public readonly status: number,
14
+ public readonly errors: CloudflareError_t[],
15
+ public readonly path: string,
16
+ ) {
17
+ super(`Cloudflare API ${status} on ${path}: ${errors.map((e) => `[${e.code}] ${e.message}`).join("; ") || "request failed"}`);
18
+ this.name = "CloudflareError";
19
+ }
20
+ }
21
+
22
+ export interface CloudflareClientOptions {
23
+ /** an API token (Bearer). Account-scoped: Workers Scripts + D1 (+ KV/R2) Edit, Account Settings Read. */
24
+ apiToken: string;
25
+ /** the account id; resolved from the token's first account when omitted. */
26
+ accountId?: string;
27
+ /** injected fetch (tests pass a recorder); defaults to the global. */
28
+ fetch?: typeof fetch;
29
+ /** API base (default the public Cloudflare API). */
30
+ baseUrl?: string;
31
+ }
32
+
33
+ export interface RequestOptions {
34
+ /** a JSON body (sets content-type + serializes). */
35
+ json?: unknown;
36
+ /** a raw body (e.g. FormData / multipart) — takes precedence over `json`. */
37
+ body?: BodyInit;
38
+ /** extra headers. */
39
+ headers?: Record<string, string>;
40
+ /** query params. */
41
+ query?: Record<string, string | number | boolean | undefined>;
42
+ /** override the Bearer token (e.g. an assets-upload JWT). */
43
+ token?: string;
44
+ }
45
+
46
+ const API = "https://api.cloudflare.com/client/v4";
47
+
48
+ export class CloudflareClient {
49
+ private readonly token: string;
50
+ private readonly doFetch: typeof fetch;
51
+ private readonly base: string;
52
+ accountId: string | undefined;
53
+
54
+ constructor(opts: CloudflareClientOptions) {
55
+ if (!opts.apiToken) throw new Error("@suluk/cloudflare: apiToken is required");
56
+ this.token = opts.apiToken;
57
+ this.accountId = opts.accountId;
58
+ this.doFetch = opts.fetch ?? globalThis.fetch;
59
+ this.base = opts.baseUrl ?? API;
60
+ }
61
+
62
+ /** Make a request and return the unwrapped `result`, throwing a CloudflareError when `success` is false. */
63
+ async request<T = unknown>(method: string, path: string, opts: RequestOptions = {}): Promise<T> {
64
+ const qs = opts.query
65
+ ? "?" + Object.entries(opts.query).filter(([, v]) => v !== undefined).map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join("&")
66
+ : "";
67
+ const headers: Record<string, string> = { authorization: `Bearer ${opts.token ?? this.token}`, ...opts.headers };
68
+ let body = opts.body;
69
+ if (body === undefined && opts.json !== undefined) { headers["content-type"] = "application/json"; body = JSON.stringify(opts.json); }
70
+ const res = await this.doFetch(`${this.base}${path}${qs}`, { method, headers, body });
71
+ const text = await res.text();
72
+ let env: { success?: boolean; errors?: CloudflareError_t[]; result?: T } = {};
73
+ try { env = text ? JSON.parse(text) : {}; } catch { /* non-JSON (e.g. an asset upload 200) */ }
74
+ if (!res.ok || env.success === false) throw new CloudflareError(res.status, env.errors?.length ? env.errors : [{ code: res.status, message: text.slice(0, 200) || res.statusText }], path);
75
+ return env.result as T;
76
+ }
77
+
78
+ /** Resolve (and cache) the account id — the first account the token can see, unless one was supplied. */
79
+ async resolveAccountId(): Promise<string> {
80
+ if (this.accountId) return this.accountId;
81
+ const accounts = await this.request<{ id: string; name: string }[]>("GET", "/accounts");
82
+ if (!accounts?.length) throw new Error("@suluk/cloudflare: the token can see no accounts (grant Account Settings: Read)");
83
+ this.accountId = accounts[0].id;
84
+ return this.accountId;
85
+ }
86
+ }
package/src/deploy.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The one-call deploy — provision → migrate → upload assets → deploy worker → push secrets → set cron, in the order
3
+ * that makes each step's output feed the next (D1 id + assets JWT become worker bindings; secrets are set AFTER the
4
+ * script exists and preserved on redeploy via keep_bindings). Pure over an injected CloudflareClient + a resolved
5
+ * PLAN (bytes, not paths), so it's fully unit-testable; a thin disk-reading wrapper lives in the app's deploy script.
6
+ */
7
+ import { CloudflareClient, type CloudflareClientOptions } from "./client";
8
+ import { provisionD1, provisionKvNamespace, provisionR2Bucket, applyMigrations, putSecrets, type Migration } from "./resources";
9
+ import { uploadAssets, type AssetFile } from "./assets";
10
+ import { deployWorker, putCronTriggers, type WorkerBinding } from "./worker";
11
+
12
+ export interface DeployPlan {
13
+ scriptName: string;
14
+ /** the bundled worker ES module. */
15
+ module: string;
16
+ mainModule?: string;
17
+ compatibilityDate: string;
18
+ compatibilityFlags?: string[];
19
+ /** provision + bind a D1 database, applying each migration once (ledger-tracked, baseline-safe). */
20
+ d1?: { binding: string; databaseName: string; migrations?: Migration[] };
21
+ /** provision + bind KV namespaces (binding → title). */
22
+ kv?: { binding: string; title: string }[];
23
+ /** provision + bind R2 buckets (binding → bucketName). */
24
+ r2?: { binding: string; bucketName: string }[];
25
+ /** static assets to serve (uploaded; bound as ASSETS by default). */
26
+ assets?: AssetFile[];
27
+ assetsBinding?: string;
28
+ assetsConfig?: Record<string, unknown>;
29
+ /** plain-text vars. */
30
+ vars?: Record<string, string>;
31
+ /** encrypted secrets (empty values skipped). */
32
+ secrets?: Record<string, string | undefined>;
33
+ /** cron triggers. */
34
+ crons?: string[];
35
+ observability?: boolean;
36
+ }
37
+
38
+ export interface DeployResult {
39
+ accountId: string;
40
+ scriptName: string;
41
+ d1?: { binding: string; id: string };
42
+ kv: { binding: string; id: string }[];
43
+ r2: { binding: string; name: string }[];
44
+ assetsUploaded: number;
45
+ secretsSet: string[];
46
+ crons: string[];
47
+ }
48
+
49
+ export type DeployLog = (msg: string) => void;
50
+
51
+ /** Orchestrate a full deploy over a client + plan. `log` narrates each step. */
52
+ export async function deploy(cf: CloudflareClient, plan: DeployPlan, log: DeployLog = () => {}): Promise<DeployResult> {
53
+ const accountId = await cf.resolveAccountId();
54
+ log(`account ${accountId} · script "${plan.scriptName}"`);
55
+ const bindings: WorkerBinding[] = [];
56
+
57
+ let d1: DeployResult["d1"];
58
+ if (plan.d1) {
59
+ const db = await provisionD1(cf, plan.d1.databaseName);
60
+ log(`D1 "${plan.d1.databaseName}" → ${db.uuid}`);
61
+ bindings.push({ type: "d1", name: plan.d1.binding, id: db.uuid });
62
+ d1 = { binding: plan.d1.binding, id: db.uuid };
63
+ if (plan.d1.migrations?.length) {
64
+ const newly = await applyMigrations(cf, db.uuid, plan.d1.migrations);
65
+ log(newly.length ? ` migrations applied/baselined: ${newly.join(", ")}` : ` migrations: all up to date`);
66
+ }
67
+ }
68
+
69
+ const kv: DeployResult["kv"] = [];
70
+ for (const k of plan.kv ?? []) { const ns = await provisionKvNamespace(cf, k.title); bindings.push({ type: "kv_namespace", name: k.binding, namespace_id: ns.id }); kv.push({ binding: k.binding, id: ns.id }); log(`KV "${k.title}" → ${ns.id}`); }
71
+
72
+ const r2: DeployResult["r2"] = [];
73
+ for (const b of plan.r2 ?? []) { const bk = await provisionR2Bucket(cf, b.bucketName); bindings.push({ type: "r2_bucket", name: b.binding, bucket_name: bk.name }); r2.push({ binding: b.binding, name: bk.name }); log(`R2 "${bk.name}" bound`); }
74
+
75
+ let assetsJwt: string | null = null;
76
+ if (plan.assets?.length) { assetsJwt = await uploadAssets(cf, plan.scriptName, plan.assets); log(`assets: ${plan.assets.length} files uploaded`); }
77
+
78
+ await deployWorker(cf, {
79
+ name: plan.scriptName, module: plan.module, mainModule: plan.mainModule,
80
+ compatibilityDate: plan.compatibilityDate, compatibilityFlags: plan.compatibilityFlags,
81
+ bindings, vars: plan.vars,
82
+ assets: { jwt: assetsJwt, binding: plan.assetsBinding, config: plan.assetsConfig },
83
+ observability: plan.observability,
84
+ });
85
+ log(`worker "${plan.scriptName}" deployed`);
86
+
87
+ const secretsSet = plan.secrets ? await putSecrets(cf, plan.scriptName, plan.secrets) : [];
88
+ if (secretsSet.length) log(`secrets set: ${secretsSet.join(", ")}`);
89
+
90
+ if (plan.crons?.length) { await putCronTriggers(cf, plan.scriptName, plan.crons); log(`crons: ${plan.crons.join(" · ")}`); }
91
+
92
+ return { accountId, scriptName: plan.scriptName, d1, kv, r2, assetsUploaded: plan.assets?.length ?? 0, secretsSet, crons: plan.crons ?? [] };
93
+ }
94
+
95
+ /** Convenience: build a client from token/account options and run a deploy. */
96
+ export async function deployWith(opts: CloudflareClientOptions, plan: DeployPlan, log?: DeployLog): Promise<DeployResult> {
97
+ return deploy(new CloudflareClient(opts), plan, log);
98
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @suluk/cloudflare — API-driven provisioning + deployment for a Suluk app on Cloudflare, no wrangler CLI. A typed
3
+ * REST client, idempotent provisioners (D1 / KV / R2 / secrets), the Workers module-script + static-assets upload
4
+ * flow, and a one-call `deploy()` that wires them in dependency order. The platform that ships itself, shipping
5
+ * itself — readable, testable, and the same contract-first discipline as the rest of the suite. CANDIDATE tooling.
6
+ */
7
+ export { CloudflareClient, CloudflareError, type CloudflareClientOptions, type RequestOptions } from "./client";
8
+ export { provisionD1, queryD1, applyMigrations, provisionKvNamespace, provisionR2Bucket, putSecret, putSecrets, type D1Database, type KvNamespace, type Migration } from "./resources";
9
+ export { uploadAssets, assetHash, type AssetFile, type UploadSession } from "./assets";
10
+ export { deployWorker, putCronTriggers, type DeployWorkerOptions, type WorkerBinding } from "./worker";
11
+ export { deploy, deployWith, type DeployPlan, type DeployResult, type DeployLog } from "./deploy";
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Idempotent resource provisioners — create-OR-get, so `deploy` can run repeatedly without "already exists" errors.
3
+ * Each resolves the account id off the client and uses the Cloudflare API's list-then-create pattern.
4
+ */
5
+ import type { CloudflareClient } from "./client";
6
+
7
+ export interface D1Database {
8
+ uuid: string;
9
+ name: string;
10
+ }
11
+
12
+ /** Create-or-get a D1 database by name. */
13
+ export async function provisionD1(cf: CloudflareClient, name: string): Promise<D1Database> {
14
+ const acct = await cf.resolveAccountId();
15
+ const existing = await cf.request<D1Database[]>("GET", `/accounts/${acct}/d1/database`, { query: { name } });
16
+ const hit = (existing ?? []).find((d) => d.name === name);
17
+ if (hit) return hit;
18
+ return cf.request<D1Database>("POST", `/accounts/${acct}/d1/database`, { json: { name } });
19
+ }
20
+
21
+ /** Run SQL against a D1 database (D1 accepts multiple `;`-separated statements per call). */
22
+ export async function queryD1(cf: CloudflareClient, databaseId: string, sql: string): Promise<unknown> {
23
+ const acct = await cf.resolveAccountId();
24
+ return cf.request("POST", `/accounts/${acct}/d1/database/${databaseId}/query`, { json: { sql } });
25
+ }
26
+
27
+ /** Rows from a D1 query response — the API returns `[{ results, success, meta }]` (one per statement). */
28
+ function d1Rows(result: unknown): Record<string, unknown>[] {
29
+ const arr = Array.isArray(result) ? (result as { results?: Record<string, unknown>[] }[]) : [];
30
+ return arr[arr.length - 1]?.results ?? [];
31
+ }
32
+
33
+ export interface Migration {
34
+ /** a stable identifier (e.g. the file name) — recorded in the ledger so it runs at most once. */
35
+ name: string;
36
+ sql: string;
37
+ }
38
+
39
+ /** SQLite "the schema is already there" errors — benign when baselining a DB migrated before the ledger existed. */
40
+ const IDEMPOTENT_ERR = /duplicate column|already exists|duplicate table/i;
41
+
42
+ /**
43
+ * Apply D1 migrations with a LEDGER (`_suluk_migrations`) so each runs exactly once — the missing piece that makes a
44
+ * redeploy safe. A migration not yet in the ledger is run and recorded; if it fails because the schema is ALREADY
45
+ * present (a DB migrated by raw execute before tracking existed), that idempotency error is swallowed and the
46
+ * migration is baselined (recorded), not fatal. Any other SQL error aborts. Returns the names newly recorded.
47
+ */
48
+ export async function applyMigrations(cf: CloudflareClient, databaseId: string, migrations: Migration[], now: () => number = () => Date.now()): Promise<string[]> {
49
+ const acct = await cf.resolveAccountId();
50
+ const run = (sql: string) => cf.request<unknown>("POST", `/accounts/${acct}/d1/database/${databaseId}/query`, { json: { sql } });
51
+ await run("CREATE TABLE IF NOT EXISTS _suluk_migrations (name TEXT PRIMARY KEY, applied_at INTEGER)");
52
+ const applied = new Set(d1Rows(await run("SELECT name FROM _suluk_migrations")).map((r) => String(r.name)));
53
+ const newly: string[] = [];
54
+ for (const m of migrations) {
55
+ if (applied.has(m.name)) continue;
56
+ try {
57
+ await run(m.sql);
58
+ } catch (e) {
59
+ if (!IDEMPOTENT_ERR.test((e as Error).message)) throw e; // a real error — surface it
60
+ // else: schema already present (pre-ledger) → baseline below, don't abort
61
+ }
62
+ await run(`INSERT OR IGNORE INTO _suluk_migrations (name, applied_at) VALUES ('${m.name.replace(/'/g, "''")}', ${now()})`);
63
+ newly.push(m.name);
64
+ }
65
+ return newly;
66
+ }
67
+
68
+ export interface KvNamespace {
69
+ id: string;
70
+ title: string;
71
+ }
72
+
73
+ /** Create-or-get a Workers KV namespace by title (e.g. a sessions or rate-limit store). */
74
+ export async function provisionKvNamespace(cf: CloudflareClient, title: string): Promise<KvNamespace> {
75
+ const acct = await cf.resolveAccountId();
76
+ const list = await cf.request<KvNamespace[]>("GET", `/accounts/${acct}/storage/kv/namespaces`, { query: { per_page: 100 } });
77
+ const hit = (list ?? []).find((n) => n.title === title);
78
+ if (hit) return hit;
79
+ return cf.request<KvNamespace>("POST", `/accounts/${acct}/storage/kv/namespaces`, { json: { title } });
80
+ }
81
+
82
+ /** Create-or-get an R2 bucket by name (e.g. media/upload storage). */
83
+ export async function provisionR2Bucket(cf: CloudflareClient, name: string): Promise<{ name: string }> {
84
+ const acct = await cf.resolveAccountId();
85
+ const res = await cf.request<{ buckets?: { name: string }[] }>("GET", `/accounts/${acct}/r2/buckets`);
86
+ const hit = (res?.buckets ?? []).find((b) => b.name === name);
87
+ if (hit) return hit;
88
+ return cf.request<{ name: string }>("POST", `/accounts/${acct}/r2/buckets`, { json: { name } });
89
+ }
90
+
91
+ /** Set ONE Worker secret (an encrypted `secret_text` binding). The script must already exist. */
92
+ export async function putSecret(cf: CloudflareClient, scriptName: string, name: string, value: string): Promise<void> {
93
+ const acct = await cf.resolveAccountId();
94
+ await cf.request("PUT", `/accounts/${acct}/workers/scripts/${scriptName}/secrets`, { json: { name, text: value, type: "secret_text" } });
95
+ }
96
+
97
+ /** Set many secrets, skipping empty/undefined values; returns the names actually set. */
98
+ export async function putSecrets(cf: CloudflareClient, scriptName: string, secrets: Record<string, string | undefined>): Promise<string[]> {
99
+ const set: string[] = [];
100
+ for (const [name, value] of Object.entries(secrets)) {
101
+ if (value) { await putSecret(cf, scriptName, name, value); set.push(name); }
102
+ }
103
+ return set;
104
+ }
package/src/worker.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Upload a Workers MODULE script via the multipart `PUT /workers/scripts/{name}` endpoint: a `metadata` JSON part
3
+ * (main_module + compatibility + bindings + assets + observability) plus the bundled ES-module part. Vars become
4
+ * `plain_text` bindings; a static-assets JWT becomes the `assets` binding + metadata. `keep_bindings` preserves
5
+ * existing secret bindings across redeploys, so you set secrets once and they survive.
6
+ */
7
+ import type { CloudflareClient } from "./client";
8
+
9
+ export interface WorkerBinding {
10
+ type: string;
11
+ name: string;
12
+ [k: string]: unknown;
13
+ }
14
+
15
+ export interface DeployWorkerOptions {
16
+ name: string;
17
+ /** the bundled ES-module source. */
18
+ module: string;
19
+ /** the module filename referenced as `main_module` (default "worker.js"). */
20
+ mainModule?: string;
21
+ compatibilityDate: string;
22
+ compatibilityFlags?: string[];
23
+ /** typed bindings (d1, kv_namespace, r2_bucket, durable_object_namespace, …). */
24
+ bindings?: WorkerBinding[];
25
+ /** plain-text vars → `plain_text` bindings. */
26
+ vars?: Record<string, string>;
27
+ /** the static-assets completion JWT (from uploadAssets) + the binding name + assets config. */
28
+ assets?: { jwt: string | null; binding?: string; config?: Record<string, unknown> };
29
+ /** cron triggers (e.g. ["0 * * * *"]). */
30
+ observability?: boolean;
31
+ /** preserve bindings of these types from the prior version (default keeps secrets across deploys). */
32
+ keepBindings?: string[];
33
+ }
34
+
35
+ export async function deployWorker(cf: CloudflareClient, opts: DeployWorkerOptions): Promise<{ id?: string }> {
36
+ const acct = await cf.resolveAccountId();
37
+ const main = opts.mainModule ?? "worker.js";
38
+
39
+ const bindings: WorkerBinding[] = [...(opts.bindings ?? [])];
40
+ for (const [name, text] of Object.entries(opts.vars ?? {})) bindings.push({ type: "plain_text", name, text });
41
+ if (opts.assets?.jwt) bindings.push({ type: "assets", name: opts.assets.binding ?? "ASSETS" });
42
+
43
+ const metadata: Record<string, unknown> = {
44
+ main_module: main,
45
+ compatibility_date: opts.compatibilityDate,
46
+ ...(opts.compatibilityFlags?.length ? { compatibility_flags: opts.compatibilityFlags } : {}),
47
+ bindings,
48
+ keep_bindings: opts.keepBindings ?? ["secret_text", "secret_key"],
49
+ ...(opts.assets?.jwt ? { assets: { jwt: opts.assets.jwt, ...(opts.assets.config ? { config: opts.assets.config } : {}) } } : {}),
50
+ ...(opts.observability !== undefined ? { observability: { enabled: opts.observability } } : {}),
51
+ };
52
+
53
+ const form = new FormData();
54
+ form.append("metadata", new Blob([JSON.stringify(metadata)], { type: "application/json" }));
55
+ form.append(main, new Blob([opts.module], { type: "application/javascript+module" }), main);
56
+
57
+ return cf.request<{ id?: string }>("PUT", `/accounts/${acct}/workers/scripts/${opts.name}`, { body: form });
58
+ }
59
+
60
+ /** Set the cron triggers for a script (separate endpoint — metadata doesn't carry them). */
61
+ export async function putCronTriggers(cf: CloudflareClient, scriptName: string, crons: string[]): Promise<void> {
62
+ const acct = await cf.resolveAccountId();
63
+ await cf.request("PUT", `/accounts/${acct}/workers/scripts/${scriptName}/schedules`, { json: crons.map((cron) => ({ cron })) });
64
+ }
@@ -0,0 +1,135 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { CloudflareClient, CloudflareError, deploy, provisionD1, putSecrets, applyMigrations, assetHash, type AssetFile } from "../src/index";
3
+
4
+ /** A routing mock fetch: returns the CF `{success,result}` envelope; records every call. */
5
+ function mockCf(routes: [RegExp, unknown | ((ctx: { body: BodyInit | null | undefined }) => unknown)][]) {
6
+ const calls: { method: string; path: string; query: Record<string, string>; body: BodyInit | null | undefined; token?: string }[] = [];
7
+ const fetch = (async (url: string, init?: RequestInit) => {
8
+ const u = new URL(url);
9
+ const path = u.pathname.replace("/client/v4", ""); // match the logical path, not the API base prefix
10
+ const method = init?.method ?? "GET";
11
+ const auth = (init?.headers as Record<string, string>)?.authorization;
12
+ calls.push({ method, path, query: Object.fromEntries(u.searchParams), body: init?.body, token: auth?.replace("Bearer ", "") });
13
+ for (const [pat, handler] of routes) {
14
+ if (pat.test(`${method} ${path}`)) {
15
+ const result = typeof handler === "function" ? (handler as (c: { body: BodyInit | null | undefined }) => unknown)({ body: init?.body }) : handler;
16
+ return new Response(JSON.stringify({ success: true, errors: [], result }), { status: 200 });
17
+ }
18
+ }
19
+ return new Response(JSON.stringify({ success: false, errors: [{ code: 404, message: `no route: ${method} ${path}` }] }), { status: 404 });
20
+ }) as unknown as typeof globalThis.fetch;
21
+ return { fetch, calls };
22
+ }
23
+
24
+ describe("CloudflareClient", () => {
25
+ test("unwraps the result envelope + resolves the account id", async () => {
26
+ const { fetch } = mockCf([[/GET \/accounts$/, [{ id: "acct_1", name: "Acme" }]]]);
27
+ const cf = new CloudflareClient({ apiToken: "t", fetch });
28
+ expect(await cf.resolveAccountId()).toBe("acct_1");
29
+ expect(await cf.resolveAccountId()).toBe("acct_1"); // cached (no second call needed)
30
+ });
31
+ test("throws CloudflareError carrying the API's error codes on success:false", async () => {
32
+ const fetch = (async () => new Response(JSON.stringify({ success: false, errors: [{ code: 10000, message: "Authentication error" }] }), { status: 403 })) as unknown as typeof globalThis.fetch;
33
+ const cf = new CloudflareClient({ apiToken: "t", accountId: "a", fetch });
34
+ await expect(cf.request("GET", "/x")).rejects.toThrow(CloudflareError);
35
+ await expect(cf.request("GET", "/x")).rejects.toThrow("10000");
36
+ });
37
+ });
38
+
39
+ describe("provisioners — idempotent create-or-get", () => {
40
+ test("provisionD1 returns the existing DB (no create) when one matches", async () => {
41
+ const { fetch, calls } = mockCf([[/GET .*\/d1\/database$/, [{ uuid: "db_existing", name: "saasuluk-db" }]]]);
42
+ const cf = new CloudflareClient({ apiToken: "t", accountId: "a", fetch });
43
+ const db = await provisionD1(cf, "saasuluk-db");
44
+ expect(db.uuid).toBe("db_existing");
45
+ expect(calls.some((c) => c.method === "POST" && /d1\/database$/.test(c.path))).toBe(false); // never created
46
+ });
47
+ test("provisionD1 creates when none matches", async () => {
48
+ const { fetch } = mockCf([[/GET .*\/d1\/database$/, []], [/POST .*\/d1\/database$/, { uuid: "db_new", name: "saasuluk-db" }]]);
49
+ const cf = new CloudflareClient({ apiToken: "t", accountId: "a", fetch });
50
+ expect((await provisionD1(cf, "saasuluk-db")).uuid).toBe("db_new");
51
+ });
52
+ test("assetHash is a 32-char (16-byte) truncated SHA-256 — the API rejects the full 64", async () => {
53
+ const h = await assetHash(new TextEncoder().encode("hello"));
54
+ expect(h).toMatch(/^[0-9a-f]{32}$/);
55
+ });
56
+
57
+ test("applyMigrations runs only un-recorded migrations + baselines an 'already exists' error", async () => {
58
+ const ran: string[] = [];
59
+ const ok = (result: unknown) => new Response(JSON.stringify({ success: true, errors: [], result }), { status: 200 });
60
+ const fetch = (async (_url: string, init?: RequestInit) => {
61
+ const sql = JSON.parse(init!.body as string).sql as string;
62
+ ran.push(sql);
63
+ if (/SELECT name FROM _suluk_migrations/.test(sql)) return ok([{ results: [{ name: "0001_done.sql" }] }]);
64
+ if (/__dup__/.test(sql)) return new Response(JSON.stringify({ success: false, errors: [{ code: 7500, message: "duplicate column name: x: SQLITE_ERROR" }] }), { status: 400 });
65
+ return ok([{ results: [] }]);
66
+ }) as unknown as typeof globalThis.fetch;
67
+ const cf = new CloudflareClient({ apiToken: "t", accountId: "a", fetch });
68
+ const newly = await applyMigrations(cf, "db", [
69
+ { name: "0001_done.sql", sql: "SHOULD_NOT_RUN" }, // already in the ledger → skipped
70
+ { name: "0002_new.sql", sql: "CREATE TABLE n(id)" }, // runs
71
+ { name: "0003_dup.sql", sql: "ALTER __dup__" }, // idempotency error → baselined, not fatal
72
+ ], () => 1700000000000);
73
+ expect(newly).toEqual(["0002_new.sql", "0003_dup.sql"]);
74
+ expect(ran).not.toContain("SHOULD_NOT_RUN");
75
+ });
76
+
77
+ test("putSecrets sets the non-empty secrets only", async () => {
78
+ const { fetch, calls } = mockCf([[/PUT .*\/secrets$/, {}]]);
79
+ const cf = new CloudflareClient({ apiToken: "t", accountId: "a", fetch });
80
+ const set = await putSecrets(cf, "saasuluk", { A: "1", B: "", C: undefined, D: "4" });
81
+ expect(set).toEqual(["A", "D"]);
82
+ expect(calls.filter((c) => /\/secrets$/.test(c.path)).length).toBe(2);
83
+ });
84
+ });
85
+
86
+ describe("deploy() — full orchestration in dependency order", () => {
87
+ test("provisions D1, migrates, uploads assets, deploys the worker (correct metadata), sets secrets + crons", async () => {
88
+ const { fetch, calls } = mockCf([
89
+ [/GET \/accounts$/, [{ id: "acct_1" }]],
90
+ [/GET .*\/d1\/database$/, []],
91
+ [/POST .*\/d1\/database$/, { uuid: "db_1", name: "saasuluk-db" }],
92
+ [/POST .*\/d1\/database\/db_1\/query$/, [{ results: [] }]], // ledger empty → migration runs
93
+ [/POST .*\/assets-upload-session$/, { jwt: "session_jwt", buckets: [] }], // all cached → completion = session jwt
94
+ [/PUT .*\/workers\/scripts\/saasuluk$/, { id: "saasuluk" }],
95
+ [/PUT .*\/workers\/scripts\/saasuluk\/secrets$/, {}],
96
+ [/PUT .*\/workers\/scripts\/saasuluk\/schedules$/, []],
97
+ ]);
98
+ const cf = new CloudflareClient({ apiToken: "t", fetch });
99
+ const assets: AssetFile[] = [{ path: "/index.html", bytes: new TextEncoder().encode("<!doctype html>"), contentType: "text/html" }];
100
+ const res = await deploy(cf, {
101
+ scriptName: "saasuluk",
102
+ module: "export default { fetch(){ return new Response('ok') } }",
103
+ compatibilityDate: "2026-06-01",
104
+ compatibilityFlags: ["nodejs_compat"],
105
+ d1: { binding: "DB", databaseName: "saasuluk-db", migrations: [{ name: "0000_domain.sql", sql: "CREATE TABLE t (id INTEGER);" }] },
106
+ assets,
107
+ vars: { STRIPE_METER_EVENT_NAME: "saasuluk_cost" },
108
+ secrets: { BETTER_AUTH_SECRET: "shh", MISSING: "" },
109
+ crons: ["0 * * * *"],
110
+ observability: true,
111
+ });
112
+
113
+ expect(res.d1).toEqual({ binding: "DB", id: "db_1" });
114
+ expect(res.assetsUploaded).toBe(1);
115
+ expect(res.secretsSet).toEqual(["BETTER_AUTH_SECRET"]);
116
+ expect(res.crons).toEqual(["0 * * * *"]);
117
+
118
+ // the worker PUT carried the right metadata (parse the multipart)
119
+ const put = calls.find((c) => c.method === "PUT" && /\/workers\/scripts\/saasuluk$/.test(c.path))!;
120
+ const meta = JSON.parse(await (put.body as FormData).get("metadata")!.text());
121
+ expect(meta.main_module).toBe("worker.js");
122
+ expect(meta.compatibility_flags).toEqual(["nodejs_compat"]);
123
+ expect(meta.bindings).toContainEqual({ type: "d1", name: "DB", id: "db_1" });
124
+ expect(meta.bindings).toContainEqual({ type: "plain_text", name: "STRIPE_METER_EVENT_NAME", text: "saasuluk_cost" });
125
+ expect(meta.bindings).toContainEqual({ type: "assets", name: "ASSETS" });
126
+ expect(meta.assets.jwt).toBe("session_jwt");
127
+ expect(meta.keep_bindings).toContain("secret_text"); // secrets survive redeploys
128
+ expect(meta.observability).toEqual({ enabled: true });
129
+
130
+ // ordering: D1 provisioned + migrated BEFORE the worker deploy; secrets AFTER
131
+ const idx = (re: RegExp) => calls.findIndex((c) => re.test(`${c.method} ${c.path}`));
132
+ expect(idx(/POST .*\/d1\/database\/db_1\/query/)).toBeLessThan(idx(/PUT .*\/workers\/scripts\/saasuluk$/));
133
+ expect(idx(/PUT .*\/workers\/scripts\/saasuluk$/)).toBeLessThan(idx(/\/secrets$/));
134
+ });
135
+ });