UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

717 lines (636 loc) 22 kB
import { $inject, Alepha, AlephaError, type TSchema, z } from "alepha"; import { $logger } from "alepha/logger"; import type { CloudflareAccount, CloudflareApiError, CloudflareD1, CloudflareDeployment, CloudflareHyperdrive, CloudflareKV, CloudflareQueue, CloudflareQueueConsumer, CloudflareR2, CloudflareR2Token, CloudflareSecret, CloudflareVersion, CloudflareWorker, } from "../schemas/cloudflare.ts"; import { cloudflareAccountSchema, cloudflareD1Schema, cloudflareDeploymentListSchema, cloudflareHyperdriveSchema, cloudflareKVSchema, cloudflareQueueConsumerSchema, cloudflareQueueSchema, cloudflareR2Schema, cloudflareR2TokenSchema, cloudflareSecretSchema, cloudflareVersionSchema, cloudflareWorkerSchema, createD1BodySchema, createHyperdriveBodySchema, createKVBodySchema, createQueueBodySchema, createR2BodySchema, createR2TokenBodySchema, putSecretBodySchema, } from "../schemas/cloudflare.ts"; import { WranglerApi } from "./WranglerApi.ts"; export type { CloudflareD1, CloudflareDeployment, CloudflareHyperdrive, CloudflareKV, CloudflareQueue, CloudflareQueueConsumer, CloudflareR2, CloudflareR2Token, CloudflareSecret, CloudflareVersion, CloudflareWorker, }; // --------------------------------------------------------------------------- // API client // --------------------------------------------------------------------------- /** * Thin wrapper over the Cloudflare REST API. * * Uses `wrangler auth token` to obtain credentials, * then calls `fetch()` directly for all CRUD operations. */ export class CloudflareApi { protected static readonly BASE = "https://api.cloudflare.com/client/v4"; protected readonly log = $logger(); protected readonly alepha = $inject(Alepha); protected readonly wrangler = $inject(WranglerApi); protected token?: string; protected accountId?: string; protected jurisdiction?: "eu" | "fedramp"; /** * Set the Cloudflare data jurisdiction for R2 and D1 resources. * * R2 buckets and D1 databases created under a jurisdiction live in a * separate namespace — every R2 API call (list/create/delete) must include * the `cf-r2-jurisdiction` header, and D1 create must include the field * in the request body. Omit / pass `undefined` for the default (global). */ public setJurisdiction(jurisdiction?: "eu" | "fedramp"): void { this.jurisdiction = jurisdiction; } /** * Override the Cloudflare account ID (from platform config). * * When unset, `resolveAccountId` falls back to `CLOUDFLARE_ACCOUNT_ID` env * var or the token's single account. */ public setAccountId(accountId?: string): void { this.accountId = accountId; } // ------------------------------------------------------------------------- // Auth // ------------------------------------------------------------------------- /** * Obtain the current auth token from wrangler. */ public async resolveToken(): Promise<string> { if (this.token) { return this.token; } this.token = await this.wrangler.getAuthToken(); return this.token; } /** * Resolve the Cloudflare account ID. * * Calls /accounts and picks the first one. Cached after first call. */ public async resolveAccountId(): Promise<string> { if (this.accountId) { return this.accountId; } const fromEnv = process.env.CLOUDFLARE_ACCOUNT_ID; if (fromEnv) { this.accountId = fromEnv; return this.accountId; } const res = await this.fetch<CloudflareAccount[]>("/accounts", { schema: z.array(cloudflareAccountSchema), }); if (res.length === 0) { throw new AlephaError("No Cloudflare accounts found for this token."); } if (res.length > 1) { const list = res.map((a) => ` - ${a.id} ${a.name}`).join("\n"); throw new AlephaError( `Cloudflare token has access to ${res.length} accounts; set ` + `\`CLOUDFLARE_ACCOUNT_ID\` or the \`accountId\` field in your ` + `platform config to pick one:\n${list}`, ); } this.accountId = res[0].id; return this.accountId; } // ------------------------------------------------------------------------- // D1 // ------------------------------------------------------------------------- public async listD1(): Promise<CloudflareD1[]> { const accountId = await this.resolveAccountId(); return await this.paginate<CloudflareD1>( `/accounts/${accountId}/d1/database`, cloudflareD1Schema, ); } public async createD1( name: string, location = "weur", // TODO: move to config (or auto-resolve based on account info, or ask ?) ): Promise<CloudflareD1> { const accountId = await this.resolveAccountId(); // When jurisdiction is set, `primary_location_hint` is silently ignored // by the API, so omit it to avoid confusion. const body: Record<string, unknown> = { name }; if (this.jurisdiction) { body.jurisdiction = this.jurisdiction; } else { body.primary_location_hint = location; } return await this.fetch<CloudflareD1>( `/accounts/${accountId}/d1/database`, { method: "POST", body, bodySchema: createD1BodySchema, schema: cloudflareD1Schema, }, ); } public async deleteD1(databaseId: string): Promise<void> { const accountId = await this.resolveAccountId(); await this.fetch(`/accounts/${accountId}/d1/database/${databaseId}`, { method: "DELETE", }); } // ------------------------------------------------------------------------- // KV // ------------------------------------------------------------------------- public async listKV(): Promise<CloudflareKV[]> { const accountId = await this.resolveAccountId(); return await this.paginate<CloudflareKV>( `/accounts/${accountId}/storage/kv/namespaces`, cloudflareKVSchema, 100, // KV list caps at 100 per page ); } public async createKV(title: string): Promise<CloudflareKV> { const accountId = await this.resolveAccountId(); return await this.fetch<CloudflareKV>( `/accounts/${accountId}/storage/kv/namespaces`, { method: "POST", body: { title }, bodySchema: createKVBodySchema, schema: cloudflareKVSchema, }, ); } public async deleteKV(namespaceId: string): Promise<void> { const accountId = await this.resolveAccountId(); await this.fetch( `/accounts/${accountId}/storage/kv/namespaces/${namespaceId}`, { method: "DELETE" }, ); } // ------------------------------------------------------------------------- // R2 // ------------------------------------------------------------------------- public async listR2(): Promise<CloudflareR2[]> { const accountId = await this.resolveAccountId(); return await this.paginateCursor<CloudflareR2>( `/accounts/${accountId}/r2/buckets`, "buckets", cloudflareR2Schema, ); } public async createR2(name: string): Promise<void> { const accountId = await this.resolveAccountId(); await this.fetch(`/accounts/${accountId}/r2/buckets`, { method: "POST", body: { name }, bodySchema: createR2BodySchema, }); } public async deleteR2(name: string): Promise<void> { const accountId = await this.resolveAccountId(); await this.fetch(`/accounts/${accountId}/r2/buckets/${name}`, { method: "DELETE", }); } /** * Mint a bucket-scoped R2 API token (S3 access key + secret) using the * current bearer token. Used by teardown to wipe a bucket over the S3 * protocol without requiring users to pre-create R2 access keys. * * The returned token should be revoked with `deleteR2Token` as soon as the * wipe is done. */ public async createR2Token( name: string, bucket: string, ): Promise<CloudflareR2Token> { const accountId = await this.resolveAccountId(); return await this.fetch<CloudflareR2Token>( `/accounts/${accountId}/r2/api-tokens`, { method: "POST", body: { name, policies: [ { effect: "allow", permissions: ["admin-read-write"], buckets: [bucket], }, ], }, bodySchema: createR2TokenBodySchema, schema: cloudflareR2TokenSchema, }, ); } public async deleteR2Token(tokenId: string): Promise<void> { const accountId = await this.resolveAccountId(); await this.fetch(`/accounts/${accountId}/r2/api-tokens/${tokenId}`, { method: "DELETE", }); } // ------------------------------------------------------------------------- // Queues // ------------------------------------------------------------------------- public async listQueues(): Promise<CloudflareQueue[]> { const accountId = await this.resolveAccountId(); return await this.paginate<CloudflareQueue>( `/accounts/${accountId}/queues`, cloudflareQueueSchema, ); } public async createQueue(name: string): Promise<CloudflareQueue> { const accountId = await this.resolveAccountId(); return await this.fetch<CloudflareQueue>(`/accounts/${accountId}/queues`, { method: "POST", body: { queue_name: name }, bodySchema: createQueueBodySchema, schema: cloudflareQueueSchema, }); } public async deleteQueue(queueId: string): Promise<void> { const accountId = await this.resolveAccountId(); await this.fetch(`/accounts/${accountId}/queues/${queueId}`, { method: "DELETE", }); } public async listQueueConsumers( queueId: string, ): Promise<CloudflareQueueConsumer[]> { const accountId = await this.resolveAccountId(); return await this.paginate<CloudflareQueueConsumer>( `/accounts/${accountId}/queues/${queueId}/consumers`, cloudflareQueueConsumerSchema, ); } public async deleteQueueConsumer( queueId: string, consumerService: string, ): Promise<void> { const accountId = await this.resolveAccountId(); const consumers = await this.listQueueConsumers(queueId); const consumer = consumers.find((c) => c.service === consumerService); if (!consumer) { return; } await this.fetch( `/accounts/${accountId}/queues/${queueId}/consumers/${consumer.consumer_id}`, { method: "DELETE" }, ); } // ------------------------------------------------------------------------- // Hyperdrive // ------------------------------------------------------------------------- public async listHyperdrive(): Promise<CloudflareHyperdrive[]> { const accountId = await this.resolveAccountId(); return await this.paginate<CloudflareHyperdrive>( `/accounts/${accountId}/hyperdrive/configs`, cloudflareHyperdriveSchema, ); } public async createHyperdrive( name: string, connectionString: string, ): Promise<CloudflareHyperdrive> { const accountId = await this.resolveAccountId(); return await this.fetch<CloudflareHyperdrive>( `/accounts/${accountId}/hyperdrive/configs`, { method: "POST", body: { name, origin: this.parseConnectionString(connectionString), }, bodySchema: createHyperdriveBodySchema, schema: cloudflareHyperdriveSchema, }, ); } public async deleteHyperdrive(configId: string): Promise<void> { const accountId = await this.resolveAccountId(); await this.fetch(`/accounts/${accountId}/hyperdrive/configs/${configId}`, { method: "DELETE", }); } // ------------------------------------------------------------------------- // Workers // ------------------------------------------------------------------------- public async getWorker( scriptName: string, ): Promise<CloudflareWorker | undefined> { const accountId = await this.resolveAccountId(); try { return await this.fetch<CloudflareWorker>( `/accounts/${accountId}/workers/scripts/${scriptName}`, { schema: cloudflareWorkerSchema }, ); } catch { return undefined; } } public async deleteWorker(scriptName: string): Promise<void> { const accountId = await this.resolveAccountId(); await this.fetch(`/accounts/${accountId}/workers/scripts/${scriptName}`, { method: "DELETE", query: { force: "true" }, }); } public async listDeployments( scriptName: string, ): Promise<CloudflareDeployment[]> { const accountId = await this.resolveAccountId(); // Deployments list is wrapped in `{ deployments }` and returns newest // first; for picking the active deployment we only need the top page. const res = await this.fetch<{ deployments: CloudflareDeployment[] }>( `/accounts/${accountId}/workers/scripts/${scriptName}/deployments`, { schema: cloudflareDeploymentListSchema, query: { per_page: "100" } }, ); return res.deployments; } public async listVersions(scriptName: string): Promise<CloudflareVersion[]> { const accountId = await this.resolveAccountId(); return await this.paginateCursor<CloudflareVersion>( `/accounts/${accountId}/workers/scripts/${scriptName}/versions`, "items", cloudflareVersionSchema, ); } // ------------------------------------------------------------------------- // Secrets // ------------------------------------------------------------------------- public async listSecrets(scriptName: string): Promise<CloudflareSecret[]> { const accountId = await this.resolveAccountId(); return await this.fetch<CloudflareSecret[]>( `/accounts/${accountId}/workers/scripts/${scriptName}/secrets`, { schema: z.array(cloudflareSecretSchema) }, ); } public async putSecret( scriptName: string, name: string, value: string, ): Promise<void> { const accountId = await this.resolveAccountId(); await this.fetch( `/accounts/${accountId}/workers/scripts/${scriptName}/secrets`, { method: "PUT", body: { name, text: value, type: "secret_text" }, bodySchema: putSecretBodySchema, }, ); } /** * Fetch the current worker bindings via the script-settings endpoint. * Used to merge new secrets into the existing binding set in one PATCH * (avoids the per-secret `putSecret` calls, each of which creates a * Cloudflare deployment — pushing 7 secrets meant 7 deployment rows). * * Secret bindings come back with `name` + `type` but no `text` (they're * write-only on Cloudflare's side); to preserve them across a PATCH we * forward each one as `{ type, name }` and Cloudflare keeps the stored * value. */ public async getWorkerSettings(scriptName: string): Promise<{ bindings: Array<{ type: string; name: string; text?: string }>; }> { const accountId = await this.resolveAccountId(); return await this.fetch<{ bindings: Array<{ type: string; name: string; text?: string }>; }>(`/accounts/${accountId}/workers/scripts/${scriptName}/settings`); } /** * Replace the worker's binding set in one call (= one Cloudflare * deployment, regardless of how many secrets are being updated). * * The endpoint expects multipart FormData with a `settings` field whose * value is a JSON-encoded `{ bindings: [...] }` — the `fetch` helper * above is JSON-only, so this one bypasses it and calls `globalThis.fetch` * directly. Mirrors what `wrangler secret bulk` does internally. */ public async patchWorkerBindings( scriptName: string, bindings: Array<{ type: string; name: string; text?: string }>, ): Promise<void> { const accountId = await this.resolveAccountId(); const token = await this.resolveToken(); const url = `${CloudflareApi.BASE}/accounts/${accountId}/workers/scripts/${scriptName}/settings`; const form = new FormData(); form.set("settings", JSON.stringify({ bindings })); const response = await globalThis.fetch(url, { method: "PATCH", headers: { Authorization: `Bearer ${token}` }, body: form, }); const json = (await response.json().catch(() => null)) as { success?: boolean; errors?: CloudflareApiError[]; } | null; if (!response.ok || !json?.success) { const messages = json?.errors?.map((e) => e.message).join(", "); throw new AlephaError( `Cloudflare API error (PATCH /workers/scripts/${scriptName}/settings): ${ messages ?? response.statusText }`, ); } } // ------------------------------------------------------------------------- // Core fetch // ------------------------------------------------------------------------- protected async fetch<T = unknown>( path: string, options: { method?: string; body?: unknown; bodySchema?: TSchema; schema?: TSchema; query?: Record<string, string>; } = {}, ): Promise<T> { const token = await this.resolveToken(); const { method = "GET", body, query } = options; let url = `${CloudflareApi.BASE}${path}`; if (query) { const params = new URLSearchParams(query); url += `?${params.toString()}`; } const headers: Record<string, string> = { Authorization: `Bearer ${token}`, }; if (this.jurisdiction && /\/r2\//.test(path)) { headers["cf-r2-jurisdiction"] = this.jurisdiction; } const init: RequestInit = { method, headers }; if (body) { headers["Content-Type"] = "application/json"; const validated = options.bodySchema ? this.alepha.codec.validate(options.bodySchema, body) : body; init.body = JSON.stringify(validated); } const response = await globalThis.fetch(url, init); const json = (await response.json()) as { success: boolean; result: T; errors: CloudflareApiError[]; result_info?: { page: number; per_page: number; total_pages?: number; count?: number; total_count?: number; }; }; if (!json.success) { const messages = json.errors.map((e) => e.message).join(", "); throw new AlephaError( `Cloudflare API error (${method} ${path}): ${messages}`, ); } if (options.schema) { return this.alepha.codec.validate(options.schema, json.result) as T; } return json.result; } /** * Paginate a page-based list endpoint (`result_info.total_pages`). * * Cloudflare defaults to `per_page=20`; we push it to 1000 (max on most * list endpoints) and loop if more pages exist. Each page is validated * against the item schema. */ protected async paginate<T>( path: string, itemSchema: TSchema, perPage = 1000, ): Promise<T[]> { const results: T[] = []; let page = 1; while (true) { const token = await this.resolveToken(); const url = `${CloudflareApi.BASE}${path}?per_page=${perPage}&page=${page}`; const headers: Record<string, string> = { Authorization: `Bearer ${token}`, }; if (this.jurisdiction && /\/r2\//.test(path)) { headers["cf-r2-jurisdiction"] = this.jurisdiction; } const response = await globalThis.fetch(url, { method: "GET", headers }); const json = (await response.json()) as { success: boolean; result: T[]; errors: CloudflareApiError[]; result_info?: { page: number; total_pages?: number }; }; if (!json.success) { const messages = json.errors.map((e) => e.message).join(", "); throw new AlephaError( `Cloudflare API error (GET ${path}): ${messages}`, ); } const validated = this.alepha.codec.validate( z.array(itemSchema), json.result, ) as T[]; results.push(...validated); const totalPages = json.result_info?.total_pages; if (!totalPages || page >= totalPages || validated.length === 0) { break; } page++; } return results; } /** * Paginate a cursor-based list endpoint where `result` is an object * containing both the items array and a `cursor` field (R2 buckets, * Workers versions). Returns the flattened item array. */ protected async paginateCursor<T>( path: string, itemsKey: string, itemSchema: TSchema, perPage = 1000, ): Promise<T[]> { const results: T[] = []; let cursor: string | undefined; while (true) { const query: Record<string, string> = { per_page: String(perPage) }; if (cursor) { query.cursor = cursor; } const res = await this.fetch<Record<string, unknown>>(path, { query }); const items = (res[itemsKey] as unknown[]) ?? []; const validated = this.alepha.codec.validate( z.array(itemSchema), items, ) as T[]; results.push(...validated); const nextCursor = res.cursor as string | undefined; if (!nextCursor || validated.length === 0) { break; } cursor = nextCursor; } return results; } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- /** * Parse a postgres:// connection string into Hyperdrive origin fields. */ protected parseConnectionString(connectionString: string): { scheme: string; host: string; port: number; database: string; user: string; password: string; } { const url = new URL(connectionString); return { scheme: "postgres", host: url.hostname, port: Number(url.port) || 5432, database: url.pathname.slice(1), user: decodeURIComponent(url.username), password: decodeURIComponent(url.password), }; } }