alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
717 lines (636 loc) • 22 kB
text/typescript
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),
};
}
}