alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
1,396 lines (1,272 loc) • 46.7 kB
text/typescript
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import {
$inject,
$state,
Alepha,
AlephaError,
type Alepha as AlephaInstance,
} from "alepha";
import {
BuildCloudflareTask,
type BuildManifest,
type BuildTaskContext,
} from "alepha/cli";
import { EnvUtils, Runner, type RunnerMethod } from "alepha/command";
import { $logger } from "alepha/logger";
import { FileSystemProvider, ShellProvider } from "alepha/system";
import { S3mini } from "s3mini";
import { platformOptions } from "../atoms/platformOptions.ts";
import { PlatformCacheProvider } from "../providers/PlatformCacheProvider.ts";
import { CloudflareApi } from "../services/CloudflareApi.ts";
import { tenantDomain } from "../services/NamingService.ts";
import { WranglerApi } from "../services/WranglerApi.ts";
import {
type AppContext,
type ExportDbOptions,
PlatformAdapter,
type PlatformContext,
type PlatformState,
} from "./PlatformAdapter.ts";
/**
* Cloudflare Workers adapter.
*
* Uses the Cloudflare REST API (via CloudflareApi) for resource provisioning
* and teardown, and wrangler CLI (via WranglerApi) for login, deploy,
* D1 migrations, and secret bulk push.
*/
export class CloudflareAdapter extends PlatformAdapter {
protected readonly log = $logger();
protected readonly fs = $inject(FileSystemProvider);
protected readonly shell = $inject(ShellProvider);
protected readonly cache = $inject(PlatformCacheProvider);
protected readonly alepha = $inject(Alepha);
protected readonly envUtils = $inject(EnvUtils);
protected readonly api = $inject(CloudflareApi);
protected readonly wrangler = $inject(WranglerApi);
protected readonly runner = $inject(Runner);
protected readonly buildTask = $inject(BuildCloudflareTask);
protected readonly options = $state(platformOptions);
protected provisionedD1Id?: string;
protected provisionedHyperdriveId?: string;
protected provisionedKVIds = new Map<string, string>();
/**
* Check if the user's DATABASE_URL points to an external Postgres database.
* If so, we use Hyperdrive instead of D1.
*
* Reads from `.env.{env}` first, falls back to `process.env`.
*/
protected async isPostgres(ctx: PlatformContext): Promise<boolean> {
const envVars = await this.envUtils.parseEnv(ctx.root, [`.env.${ctx.env}`]);
const dbUrl = envVars.DATABASE_URL ?? process.env.DATABASE_URL;
return !!dbUrl?.startsWith("postgres:");
}
/**
* Propagate the environment's data-jurisdiction setting to the API client.
*
* Must be invoked at the top of every entry point (authenticate, build,
* deploy, secrets, provision, migrate, inspect, teardown) because
* CloudflareApi is a singleton reused across env invocations.
*/
protected configureApi(ctx: PlatformContext): void {
this.api.setJurisdiction(ctx.envConfig.jurisdiction);
this.api.setAccountId(ctx.envConfig.accountId);
}
protected async runShell(
command: string,
options: Parameters<ShellProvider["run"]>[1] = {},
) {
const output = await this.shell.run(command, options);
// When the caller captured the output, echo it to the log so the user
// still sees it (uncaptured commands stream straight to the terminal).
if (options.capture) {
this.log.info(output);
}
return output;
}
// -------------------------------------------------------------------------
// authenticate
// -------------------------------------------------------------------------
async authenticate(ctx: PlatformContext, run: RunnerMethod): Promise<void> {
this.configureApi(ctx);
await run({
name: "authenticate",
handler: async () => {
await this.wrangler.ensureInstalled(ctx.root);
// Always validate the token — refresh tokens can expire between runs
// even when the cache TTL hasn't elapsed.
let needsLogin = false;
try {
await this.wrangler.getAuthToken();
} catch {
needsLogin = true;
}
if (needsLogin) {
await this.wrangler.login();
}
// Skip account resolution if cache is fresh
if (await this.cache.isLoginFresh(ctx.root, "cloudflare")) {
return;
}
// Resolve account ID via REST API (typed, no regex)
try {
const accountId = await this.api.resolveAccountId();
await this.cache.recordLogin(ctx.root, "cloudflare", accountId);
} catch {
await this.cache.recordLogin(ctx.root, "cloudflare");
}
},
});
}
// -------------------------------------------------------------------------
// build
// -------------------------------------------------------------------------
async build(ctx: AppContext, run: RunnerMethod): Promise<void> {
this.configureApi(ctx);
const appDir = ctx.root;
const env: Record<string, string> = {};
if (ctx.resources.hasDatabase) {
if (this.provisionedHyperdriveId) {
env.HYPERDRIVE_ID = this.provisionedHyperdriveId;
const envVars = await this.envUtils.parseEnv(ctx.root, [
`.env.${ctx.env}`,
]);
const pgSchema = envVars.POSTGRES_SCHEMA ?? process.env.POSTGRES_SCHEMA;
if (pgSchema) {
env.POSTGRES_SCHEMA = pgSchema;
}
} else if (this.provisionedD1Id) {
const dbName = ctx.naming.d1();
env.DATABASE_URL = `d1://${dbName}:${this.provisionedD1Id}`;
}
}
if (ctx.resources.hasBucket) {
env.R2_BUCKET_NAME = ctx.naming.r2();
}
if (ctx.resources.hasKV) {
const kvName = ctx.naming.kv();
env.CLOUDFLARE_KV_NAME = kvName;
const kvId = this.provisionedKVIds.get(kvName);
if (kvId) {
env.CLOUDFLARE_KV_ID = kvId;
}
}
if (ctx.resources.hasQueue) {
env.CLOUDFLARE_QUEUE_NAME = ctx.naming.queue();
}
// For a tenanted deploy the host is `<tenant>.<domain>`; otherwise the
// configured domain is used as-is. (V2 custom-domain override plugs in
// via tenantDomain's third arg.)
const host = tenantDomain(ctx.envConfig.domain, ctx.tenant);
if (host) {
// A wildcard host needs a CF zone for its Worker route, but `zone` is
// optional: when omitted the build derives it from the domain's last two
// labels (BuildCloudflareTask). Set it only to override that default.
env.CLOUDFLARE_DOMAIN = host;
if (ctx.envConfig.zone) {
env.CLOUDFLARE_ZONE = ctx.envConfig.zone;
}
}
if (ctx.envConfig.jurisdiction) {
env.CLOUDFLARE_JURISDICTION = ctx.envConfig.jurisdiction;
}
// Worker-to-worker service bindings (see EnvironmentConfig.services).
if (ctx.envConfig.services?.length) {
env.CLOUDFLARE_SERVICES = JSON.stringify(ctx.envConfig.services);
}
// Two paths:
// - `--prebuilt`: in-process call to BuildCloudflareTask. Reads
// `dist/manifest.json` for resources/crons/containers, reads
// per-tenant values from process.env (set below), and writes a
// fresh `dist/wrangler.jsonc` + `dist/main.cloudflare.js`. No
// Vite, no spawn, no `alepha` binary needed at the workspace
// cwd — required for Rocket, which deploys a bare prebuilt
// tarball with no `node_modules`.
// - non-prebuilt: spawn the full `alepha build` for the CLI flow,
// which still needs Vite analyze + bundle.
if (ctx.prebuilt) {
await run({
name: "alepha build -t cloudflare --prebuilt (in-process)",
handler: async () => {
await this.runBuildInProcess(appDir, env);
},
});
return;
}
const cmd = "alepha build -t cloudflare";
await run({
name: cmd,
handler: async () => {
await this.runShell(cmd, {
root: appDir,
env,
});
},
});
}
/**
* Library-embed of `alepha build -t cloudflare --prebuilt`. Loads the
* pre-built `dist/manifest.json`, sets the per-tenant env vars on
* `process.env` for the duration of the call (the task's enhance*
* methods read them directly), then runs `BuildCloudflareTask`
* against a synthetic context.
*
* `ctx.alepha` is intentionally null — in manifest mode the task
* reads resources/crons/containers from `ctx.manifest` and never
* dereferences `ctx.alepha`. Same for `entry` and `hasClient`:
* prebuilt mode skips the bundle tasks; only the wrangler.jsonc /
* worker-entrypoint emission runs.
*/
protected async runBuildInProcess(
root: string,
env: Record<string, string>,
): Promise<void> {
const manifestPath = join(root, "dist", "manifest.json");
let manifest: BuildManifest;
try {
manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
} catch (err) {
throw new AlephaError(
`Cannot read ${manifestPath}: ${(err as Error).message}. ` +
`Prebuilt deploys require dist/manifest.json (emitted by \`alepha build -t cloudflare\`).`,
);
}
const ctx: BuildTaskContext = {
// null at runtime — task takes the manifest path and never
// dereferences alepha. Cast keeps the type signature happy.
alepha: null as unknown as AlephaInstance,
options: {
target: "cloudflare",
output: { dist: "dist", public: "public" },
},
run: this.runner.run,
root,
entry: { root, server: "" },
hasClient: false,
manifest,
platformOptions: null,
flags: { prebuilt: true },
};
const previous: Record<string, string | undefined> = {};
for (const [k, v] of Object.entries(env)) {
previous[k] = process.env[k];
process.env[k] = v;
}
try {
await this.buildTask.run(ctx);
} finally {
for (const [k, prev] of Object.entries(previous)) {
if (prev === undefined) delete process.env[k];
else process.env[k] = prev;
}
}
}
// -------------------------------------------------------------------------
// deploy (wrangler — handles bundling/upload)
// -------------------------------------------------------------------------
async deploy(
ctx: AppContext,
run: RunnerMethod,
): Promise<string | undefined> {
this.configureApi(ctx);
const workerName = ctx.naming.worker();
const distDir = this.fs.join(ctx.root, "dist");
let url: string | undefined;
await run({
name: `deploy worker ${ctx.project}`,
handler: async () => {
url = await this.wrangler.deploy(
workerName,
`${distDir}/wrangler.jsonc`,
ctx.root,
);
},
});
return url;
}
// -------------------------------------------------------------------------
// secrets (wrangler — bulk push)
// -------------------------------------------------------------------------
/**
* Vars that are handled by wrangler bindings or build config.
* These should not be pushed as secrets.
*/
static readonly EXCLUDED_SECRET_KEYS = new Set([
"DATABASE_URL",
"R2_BUCKET_NAME",
"CLOUDFLARE_DOMAIN",
"CLOUDFLARE_ZONE",
"CLOUDFLARE_JURISDICTION",
"HYPERDRIVE_ID",
"POSTGRES_SCHEMA",
"NODE_ENV",
// Framework infra knobs (have defaults, never worker secrets). The
// manifest's `env` auto-list surfaces every declared `$env` key, so
// exclude these here to keep them out of the secret push even when a CI
// runner happens to set them (LOG_LEVEL, DEBUG, etc.).
"LOG_LEVEL",
"LOG_FORMAT",
"SERVER_HOST",
"SERVER_PORT",
"TRUST_PROXY",
"REACT_SSR_ENABLED",
"DATABASE_SYNC",
"DEBUG",
]);
/**
* Read the build manifest's `env` list (every key the app declares via
* `$env`) from `dist/manifest.json`. Used as the default worker-secret
* allowlist. Returns `undefined` when the manifest is absent or predates
* the `env` field, so the caller falls back to the `.env` file keys.
*/
protected async readManifestEnvKeys(
root: string,
): Promise<string[] | undefined> {
try {
const manifest = await this.fs.readJsonFile<Partial<BuildManifest>>(
this.fs.join(root, "dist", "manifest.json"),
);
return Array.isArray(manifest.env) ? manifest.env : undefined;
} catch {
return undefined;
}
}
override async secrets(
ctx: PlatformContext,
run: RunnerMethod,
): Promise<void> {
this.configureApi(ctx);
const envVars = await this.envUtils.parseEnv(ctx.root, [`.env.${ctx.env}`]);
// The key set to push, by precedence:
// 1. `platform.secrets.keys` — explicit override in alepha.config.ts.
// 2. otherwise the UNION of:
// a. `dist/manifest.json` `env` — every key the app declares via
// `$env`, captured at build time (or the `.env.<env>` file keys as
// a legacy fallback for artifacts built before the manifest carried
// `env`). Lets CI deliver declared secrets from `process.env` with
// no file on the runner.
// b. `.env.<env>.local` keys — the per-deploy override layer. External
// orchestrators (Alepha Rocket) write injected `config.vars` +
// `config.secrets` (e.g. CLUB_CONFIG_JSON, per-tenant OAuth) here,
// and those must reach the worker even though the prebuilt app
// never declared them. Only `.local` is unioned, NOT the base
// `.env.<env>` — so local infra creds (CLOUDFLARE_API_TOKEN, …)
// can't leak in.
// In every case the value resolves from `.env.<env>[.local]` first, then
// `process.env`; ambient runner vars (PATH, GITHUB_*, …) can never leak.
const declaredKeys = this.options?.secrets?.keys;
const manifestKeys = await this.readManifestEnvKeys(ctx.root);
const localKeys = Object.keys(
await this.envUtils.parseEnv(ctx.root, [`.env.${ctx.env}.local`]),
);
const keys =
declaredKeys ??
Array.from(
new Set([...(manifestKeys ?? Object.keys(envVars)), ...localKeys]),
);
// Filter out binding/build vars, VITE_* vars, and empty values
const secrets: Record<string, string> = {};
for (const key of keys) {
if (CloudflareAdapter.EXCLUDED_SECRET_KEYS.has(key)) continue;
if (key.startsWith("VITE_")) continue;
const value = envVars[key] ?? process.env[key];
if (!value) continue;
secrets[key] = value;
}
// Auto-derive PUBLIC_URL from the configured domain so absolute links
// (emails, OAuth callbacks, sitemap) resolve at runtime — the Worker
// entrypoint lifts it into `alepha.env` via `loadEnv`. Honors an explicit
// PUBLIC_URL in `.env.<env>` (already collected above); never overrides it.
if (!secrets.PUBLIC_URL) {
const url = this.publicUrl(ctx);
if (url) {
secrets.PUBLIC_URL = url;
}
}
if (Object.keys(secrets).length === 0) {
return;
}
// Push all secrets for a worker in a single PATCH so each `up` only
// mints one new deployment for the secrets step (regardless of how many
// are being updated). Loop-based `putSecret` worked but generated N
// deployment rows per push, cluttering the CF dashboard.
//
// Skip the PATCH entirely when nothing changed: we stamp a sha256 of the
// sorted secret set onto the worker as a plain_text binding called
// `ALEPHA_SECRETS_HASH`. On the next deploy we GET the settings, compare
// the stored hash to the freshly-computed one, and bail out if they
// match. The hash lives on Cloudflare (not on disk), so the cache works
// identically in CI and locally.
//
// Net deploy count per `up`:
// - code change, secrets unchanged: 1 (wrangler deploy only)
// - secrets changed: 2 (wrangler deploy + bulk PATCH)
//
// Implementation mirrors `wrangler secret bulk`:
// 1. GET current worker bindings via `/script/{name}/settings`.
// 2. Compare ALEPHA_SECRETS_HASH binding to local hash → skip on match.
// 3. Keep all non-secret bindings (D1, R2, KV, etc.) and any secret
// bindings we are NOT overwriting (forwarded as `{type,name}` only
// — CF preserves their stored values).
// 4. Add/overwrite secrets as `{type,name,text}`, plus a fresh
// ALEPHA_SECRETS_HASH binding so subsequent runs see it.
// 5. PATCH the merged binding list in one call.
const hash = computeSecretsHash(secrets);
{
const workerName = ctx.naming.worker();
await run({
name: `push secrets to ${workerName} (bulk)`,
handler: async () => {
const settings = await this.api.getWorkerSettings(workerName);
const existingBindings = settings.bindings ?? [];
const existingHashBinding = existingBindings.find(
(b) =>
b.type === "plain_text" &&
b.name === CloudflareAdapter.SECRETS_HASH_BINDING,
);
if (existingHashBinding?.text === hash) {
this.log.info(
`Secrets for ${workerName} unchanged (hash ${hash.slice(0, 8)}…), skipping push.`,
);
return;
}
const overwriting = new Set(Object.keys(secrets));
const inherit = existingBindings
.filter(
(b) =>
// Drop the old hash binding — we'll write a fresh one below.
!(
b.type === "plain_text" &&
b.name === CloudflareAdapter.SECRETS_HASH_BINDING
) &&
// Drop secret bindings we're about to overwrite. Keep the
// rest of the bindings (D1, R2, KV, untouched secrets) as
// `{type,name}` only — CF preserves stored values.
(b.type !== "secret_text" || !overwriting.has(b.name)),
)
.map((b) => ({ type: b.type, name: b.name }));
const upsert = Object.entries(secrets).map(([name, text]) => ({
type: "secret_text" as const,
name,
text,
}));
await this.api.patchWorkerBindings(workerName, [
...inherit,
...upsert,
{
type: "plain_text",
name: CloudflareAdapter.SECRETS_HASH_BINDING,
text: hash,
},
]);
},
});
}
}
/**
* Public base URL for this deploy, derived from the configured domain
* (honoring tenant subdomains). Returns undefined when no domain is set or
* the host is a wildcard — there's no single resolvable origin to point at.
*/
protected publicUrl(ctx: PlatformContext): string | undefined {
const host = tenantDomain(ctx.envConfig.domain, ctx.tenant);
if (!host || host.includes("*")) {
return undefined;
}
return `https://${host}`;
}
/**
* Plain-text binding used to fingerprint the deployed secret set so the
* next `up` can skip the PATCH when nothing has changed.
*/
static readonly SECRETS_HASH_BINDING = "ALEPHA_SECRETS_HASH";
// -------------------------------------------------------------------------
// provision (REST API)
// -------------------------------------------------------------------------
override async provision(
ctx: PlatformContext,
run: RunnerMethod,
): Promise<void> {
this.configureApi(ctx);
const needsDB = ctx.resources.hasDatabase;
const needsBucket = ctx.resources.hasBucket;
const postgres = needsDB && (await this.isPostgres(ctx));
const tasks: Array<{ name: string; handler: () => Promise<void> }> = [];
if (needsDB) {
if (postgres) {
const hdName = ctx.naming.hyperdrive();
const envVars = await this.envUtils.parseEnv(ctx.root, [
`.env.${ctx.env}`,
]);
const dbUrl = envVars.DATABASE_URL ?? process.env.DATABASE_URL!;
tasks.push({
name: `provision hyperdrive (${hdName})`,
handler: async () => {
this.provisionedHyperdriveId = await this.ensureHyperdrive(
hdName,
dbUrl,
);
},
});
} else {
const dbName = ctx.naming.d1();
tasks.push({
name: `provision d1 (${dbName})`,
handler: async () => {
this.provisionedD1Id = await this.ensureD1(dbName);
},
});
}
}
if (needsBucket) {
const bucketName = ctx.naming.r2();
tasks.push({
name: `provision r2 (${bucketName})`,
handler: async () => {
await this.ensureR2(bucketName);
},
});
}
if (ctx.resources.hasKV) {
const kvName = ctx.naming.kv();
tasks.push({
name: `provision kv (${kvName})`,
handler: async () => {
this.provisionedKVIds.set(kvName, await this.ensureKV(kvName));
},
});
}
if (ctx.resources.hasQueue) {
const queueName = ctx.naming.queue();
tasks.push({
name: `provision queue (${queueName})`,
handler: async () => {
await this.ensureQueue(queueName);
},
});
}
await run(tasks);
}
// -------------------------------------------------------------------------
// migrate (wrangler — D1 migration runner)
// -------------------------------------------------------------------------
override async migrate(
ctx: PlatformContext,
run: RunnerMethod,
): Promise<void> {
this.configureApi(ctx);
const needsDB = ctx.resources.hasDatabase;
if (!needsDB) {
return;
}
if (await this.isPostgres(ctx)) {
await this.migratePostgres(ctx, run);
} else {
await this.migrateD1(ctx, run);
}
}
override async exportDb(
ctx: PlatformContext,
run: RunnerMethod,
options: ExportDbOptions = {},
): Promise<void> {
this.configureApi(ctx);
if (!ctx.resources.hasDatabase) {
throw new AlephaError(
"No database detected for this app — nothing to export.",
);
}
if (await this.isPostgres(ctx)) {
throw new AlephaError(
"Database export currently supports Cloudflare D1 only — Postgres/Hyperdrive export (pg_dump) is not implemented yet.",
);
}
const dbName = ctx.naming.d1();
const tmpDir = this.fs.join(ctx.root, "node_modules", ".alepha");
const sqlPath = this.fs.join(tmpDir, `${dbName}.sql`);
// D1 is SQLite — the natural local snapshot is the dev DB file that
// `yarn dev` reads, so dev runs against a real remote snapshot.
const dbPath = options.output ?? this.fs.join(tmpDir, "sqlite.db");
await this.fs.mkdir(tmpDir, { recursive: true });
await run(`wrangler d1 export ${dbName} --remote --output=${sqlPath}`, {
alias: `export D1 ${dbName} → ${sqlPath}`,
});
// `sqlite3 '<db>' < dump.sql` aborts if the target already holds a
// conflicting schema — start from a clean file. run() bypasses the
// shell, so wrap the `<` redirection in `sh -c`.
await this.fs.rm(dbPath, { force: true });
await run(`sh -c "sqlite3 '${dbPath}' < '${sqlPath}'"`, {
alias: `import dump → ${dbPath}`,
});
if (!options.keepSql) {
await this.fs.rm(sqlPath, { force: true });
}
}
protected async migrateD1(
ctx: PlatformContext,
run: RunnerMethod,
): Promise<void> {
const dbName = ctx.naming.d1();
await run({
name: "migrate d1",
handler: async () => {
const migrationsDir = this.fs.join(ctx.root, "migrations", "sqlite");
const dbUrl = this.provisionedD1Id
? `d1://${dbName}:${this.provisionedD1Id}`
: `d1://${dbName}`;
const env = { DATABASE_URL: dbUrl };
// In prebuilt mode (Rocket) the tarball ships `migrations/`
// straight from the build artifact — already checked + frozen
// at pack time. Skip the live check/create cycle, which would
// need to boot the user's app to introspect schema definitions
// (impossible without the workspace's node_modules). For
// non-prebuilt CLI deploys, still run the check (or create the
// SQL when missing) so the deploy fails fast on a drifted
// schema.
if (!ctx.prebuilt) {
if (await this.fs.exists(migrationsDir)) {
await this.runShell(
`alepha db migrations check --mode ${ctx.env}`,
{ resolve: true, env },
);
} else {
await this.runShell(
`alepha db migrations create --mode ${ctx.env}`,
{ resolve: true, env },
);
}
}
// Copy migrations to dist for wrangler, apply, then clean up
const distMigrations = this.fs.join(ctx.root, "dist", "migrations");
await this.fs.cp(migrationsDir, distMigrations);
await this.wrangler.d1MigrationsApply(
dbName,
"dist/wrangler.jsonc",
ctx.root,
);
await this.fs.rm(distMigrations, { recursive: true });
},
});
}
protected async migratePostgres(
ctx: PlatformContext,
run: RunnerMethod,
): Promise<void> {
if (ctx.prebuilt) {
// Postgres + Hyperdrive prebuilt deploys need a separate
// migration story (an alepha-CLI-free `apply` against the
// packed `migrations/postgres/` dir) — not implemented yet.
// Rocket's v1 path is D1, which uses `wrangler d1 migrations
// apply` and works fine in prebuilt mode.
throw new AlephaError(
"Postgres migrations are not yet supported in prebuilt mode. Use the `alepha platform up` CLI for now.",
);
}
await run({
name: "migrate postgres",
handler: async () => {
const envVars = await this.envUtils.parseEnv(ctx.root, [
`.env.${ctx.env}`,
]);
const env: Record<string, string> = {
DATABASE_URL: envVars.DATABASE_URL ?? process.env.DATABASE_URL!,
};
if (envVars.POSTGRES_SCHEMA ?? process.env.POSTGRES_SCHEMA) {
env.POSTGRES_SCHEMA = (envVars.POSTGRES_SCHEMA ??
process.env.POSTGRES_SCHEMA)!;
}
await this.runShell(`alepha db migrations apply --mode ${ctx.env}`, {
resolve: true,
env,
});
},
});
}
// -------------------------------------------------------------------------
// inspect (REST API)
// -------------------------------------------------------------------------
async inspect(
ctx: PlatformContext,
run: RunnerMethod,
): Promise<PlatformState> {
this.configureApi(ctx);
const state: PlatformState = {
workers: [],
databases: [],
buckets: [],
kvNamespaces: [],
queues: [],
secrets: [],
};
const tasks: Array<{ name: string; handler: () => Promise<void> }> = [];
// Workers
{
const name = ctx.naming.worker();
tasks.push({
name: `inspect worker (${name})`,
handler: async () => {
try {
const deployment = await this.getActiveDeployment(name);
if (deployment) {
state.workers.push({
name,
exists: true,
version: deployment.versionId,
tag: deployment.tag,
createdAt: deployment.createdAt,
});
} else {
state.workers.push({ name, exists: false });
}
} catch {
state.workers.push({ name, exists: false });
}
},
});
}
// Database
const needsDB = ctx.resources.hasDatabase;
if (needsDB) {
if (await this.isPostgres(ctx)) {
const hdName = ctx.naming.hyperdrive();
tasks.push({
name: `inspect hyperdrive (${hdName})`,
handler: async () => {
const configs = await this.api.listHyperdrive();
const existing = configs.find((c) => c.name === hdName);
state.databases.push({
name: hdName,
exists: !!existing,
id: existing?.id,
detail: existing?.origin.host,
});
},
});
} else {
const dbName = ctx.naming.d1();
tasks.push({
name: `inspect d1 (${dbName})`,
handler: async () => {
const databases = await this.api.listD1();
const existing = databases.find((db) => db.name === dbName);
state.databases.push({
name: dbName,
exists: !!existing,
id: existing?.uuid,
});
},
});
}
}
// R2
const needsBucket = ctx.resources.hasBucket;
if (needsBucket) {
const bucketName = ctx.naming.r2();
tasks.push({
name: `inspect r2 (${bucketName})`,
handler: async () => {
const buckets = await this.api.listR2();
const existing = buckets.find((b) => b.name === bucketName);
state.buckets.push({
name: bucketName,
exists: !!existing,
id: existing?.creation_date,
});
},
});
}
if (ctx.resources.hasKV) {
const kvName = ctx.naming.kv();
tasks.push({
name: `inspect kv (${kvName})`,
handler: async () => {
const namespaces = await this.api.listKV();
const existing = namespaces.find((ns) => ns.title === kvName);
state.kvNamespaces.push({
name: kvName,
exists: !!existing,
id: existing?.id,
});
},
});
}
if (ctx.resources.hasQueue) {
const queueName = ctx.naming.queue();
tasks.push({
name: `inspect queue (${queueName})`,
handler: async () => {
const queues = await this.api.listQueues();
const existing = queues.find((q) => q.queue_name === queueName);
state.queues.push({
name: queueName,
exists: !!existing,
id: existing?.queue_id,
});
},
});
}
// Secrets
const envVars = await this.envUtils.parseEnv(ctx.root, [`.env.${ctx.env}`]);
const expectedSecrets = Object.keys(envVars).filter(
(key) =>
envVars[key] &&
!CloudflareAdapter.EXCLUDED_SECRET_KEYS.has(key) &&
!key.startsWith("VITE_"),
);
if (expectedSecrets.length > 0) {
const workerName = ctx.naming.worker();
tasks.push({
name: "inspect secrets",
handler: async () => {
try {
const deployed = await this.api.listSecrets(workerName);
const deployedNames = new Set(deployed.map((s) => s.name));
for (const key of expectedSecrets) {
state.secrets.push({
name: key,
deployed: deployedNames.has(key),
});
}
} catch {
for (const key of expectedSecrets) {
state.secrets.push({ name: key, deployed: false });
}
}
},
});
}
await run(tasks);
return state;
}
// -------------------------------------------------------------------------
// teardown (REST API)
// -------------------------------------------------------------------------
async teardown(ctx: PlatformContext, run: RunnerMethod): Promise<void> {
this.configureApi(ctx);
if (ctx.resources.hasQueue) {
const workerName = ctx.naming.worker();
const queueName = ctx.naming.queue();
await run({
name: `unbind queue consumer ${queueName}`,
handler: async () => {
try {
const queues = await this.api.listQueues();
const queue = queues.find((q) => q.queue_name === queueName);
if (queue) {
await this.api.deleteQueueConsumer(queue.queue_id, workerName);
}
} catch (error: any) {
this.log.warn(
`Failed to unbind queue consumer: ${String(error.message || "")}`,
);
}
},
});
}
// 2. Delete workers
{
const name = ctx.naming.worker();
await run({
name: `delete worker ${name}`,
handler: async () => {
try {
await this.api.deleteWorker(name);
} catch (error: any) {
this.log.warn(
`Failed to delete worker ${name}: ${String(error.message || "")}`,
);
}
},
});
}
if (ctx.resources.hasQueue) {
const name = ctx.naming.queue();
await run({
name: `delete queue ${name}`,
handler: async () => {
try {
const queues = await this.api.listQueues();
const queue = queues.find((q) => q.queue_name === name);
if (!queue) {
this.log.debug(`Queue ${name} not found — skipping.`);
return;
}
await this.api.deleteQueue(queue.queue_id);
} catch (error: any) {
this.log.warn(
`Failed to delete queue ${name}: ${String(error.message || "")}`,
);
}
},
});
}
if (ctx.resources.hasKV) {
const name = ctx.naming.kv();
await run({
name: `delete kv ${name}`,
handler: async () => {
try {
const namespaces = await this.api.listKV();
const existing = namespaces.find((ns) => ns.title === name);
if (!existing) {
this.log.debug(`KV namespace ${name} not found — skipping.`);
return;
}
await this.api.deleteKV(existing.id);
} catch (error: any) {
this.log.warn(
`Failed to delete kv ${name}: ${String(error.message || "")}`,
);
}
},
});
}
// 5. Delete R2 bucket. An empty bucket is removed by the REST DELETE
// directly; only a non-empty one needs an S3 wipe first. Crucially the
// wipe is NOT a precondition of the delete — a wipe that can't run (no
// creds) must never strand an otherwise-deletable bucket.
const needsBucket = ctx.resources.hasBucket;
if (needsBucket) {
const name = ctx.naming.r2();
await run({
name: `delete r2 ${name}`,
handler: async () => {
try {
await this.deleteR2Bucket(name, ctx);
} catch (error: any) {
const msg = String(error.message || "");
if (this.isMissingBucketError(msg)) {
this.log.debug(`Bucket ${name} not found — skipping.`);
} else {
this.log.warn(`Failed to delete r2 ${name}: ${msg}`);
}
}
},
});
}
// 6. Delete D1 or Hyperdrive
const needsDB = ctx.resources.hasDatabase;
if (needsDB) {
if (await this.isPostgres(ctx)) {
const name = ctx.naming.hyperdrive();
await run({
name: `delete hyperdrive ${name}`,
handler: async () => {
try {
const configs = await this.api.listHyperdrive();
const existing = configs.find((c) => c.name === name);
if (!existing) {
this.log.debug(`Hyperdrive ${name} not found — skipping.`);
return;
}
await this.api.deleteHyperdrive(existing.id);
} catch (error: any) {
this.log.warn(
`Failed to delete hyperdrive ${name}: ${String(error.message || "")}`,
);
}
},
});
} else {
const name = ctx.naming.d1();
await run({
name: `delete d1 ${name}`,
handler: async () => {
try {
const databases = await this.api.listD1();
const existing = databases.find((db) => db.name === name);
if (!existing) {
this.log.debug(`D1 database ${name} not found — skipping.`);
return;
}
await this.api.deleteD1(existing.uuid);
} catch (error: any) {
this.log.warn(
`Failed to delete d1 ${name}: ${String(error.message || "")}`,
);
}
},
});
}
}
}
// -------------------------------------------------------------------------
// Resource helpers (REST API)
// -------------------------------------------------------------------------
protected async ensureD1(name: string): Promise<string> {
const databases = await this.api.listD1();
const existing = databases.find((db) => db.name === name);
if (existing) {
return existing.uuid;
}
const created = await this.api.createD1(name);
return created.uuid;
}
protected async ensureHyperdrive(
name: string,
connectionString: string,
): Promise<string> {
const configs = await this.api.listHyperdrive();
const existing = configs.find((c) => c.name === name);
if (existing) {
return existing.id;
}
const created = await this.api.createHyperdrive(name, connectionString);
return created.id;
}
protected async ensureR2(name: string): Promise<void> {
const buckets = await this.api.listR2();
const existing = buckets.find((b) => b.name === name);
if (existing) {
return;
}
await this.api.createR2(name);
}
/** Whether a Cloudflare error message indicates the bucket is already gone. */
protected isMissingBucketError(msg: string): boolean {
return (
msg.includes("does not exist") ||
msg.includes("NoSuchBucket") ||
msg.includes("bucket not found")
);
}
/**
* Resolve S3 credentials for wiping an R2 bucket over the S3 protocol.
*
* Prefers the account's R2 S3 credentials from the environment
* (`S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY`) — these are already
* provisioned for the deploy (artifact registry) and are account-scoped,
* so they can empty any bucket without minting anything. Returns `null`
* when not configured, letting the caller fall back to token minting.
*/
protected resolveR2Credentials(): {
accessKeyId: string;
secretAccessKey: string;
} | null {
const accessKeyId = process.env.S3_ACCESS_KEY_ID;
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY;
if (accessKeyId && secretAccessKey) {
return { accessKeyId, secretAccessKey };
}
return null;
}
/**
* Delete an R2 bucket, emptying it first only when necessary.
*
* Cloudflare's REST `DELETE /r2/buckets/:name` succeeds on an empty bucket
* but rejects a non-empty one. So we attempt the delete directly (the
* common teardown case — no objects, no creds needed), and only on failure
* empty the bucket over the S3 protocol and retry. A missing bucket is a
* no-op, so teardown is idempotent.
*/
protected async deleteR2Bucket(
name: string,
ctx: PlatformContext,
): Promise<void> {
try {
await this.api.deleteR2(name);
return;
} catch (error: any) {
const msg = String(error.message || "");
if (this.isMissingBucketError(msg)) {
return; // already gone
}
// Most often the bucket is non-empty — empty it then retry once.
this.log.debug(
`Direct delete of r2 ${name} failed (${msg}); emptying then retrying.`,
);
}
await this.wipeR2Bucket(name, ctx);
try {
await this.api.deleteR2(name);
} catch (error: any) {
const msg = String(error.message || "");
if (this.isMissingBucketError(msg)) {
return;
}
throw error;
}
}
/**
* Empty an R2 bucket via the S3-compatible API.
*
* Cloudflare's REST API has no object-level endpoints — objects must be
* listed and deleted over the S3 protocol. We use the account's R2 S3
* credentials (`S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY`) when present;
* otherwise we fall back to minting a short-lived bucket-scoped token via
* the CF API (requires a user-scoped `CLOUDFLARE_API_TOKEN`) and revoke it
* after. When neither is available the wipe is skipped with a warning —
* the caller still attempts the delete, which succeeds for empty buckets.
*
* Also aborts any pending multipart uploads — those count as bucket
* contents from R2's perspective and would otherwise block the delete.
*/
protected async wipeR2Bucket(
bucketName: string,
ctx: PlatformContext,
): Promise<void> {
let creds = this.resolveR2Credentials();
let mintedTokenId: string | undefined;
if (!creds) {
// No env S3 creds — try minting a bucket-scoped token. This needs a
// user-scoped `CLOUDFLARE_API_TOKEN`; an account-scoped one (or the
// wrangler OAuth bearer) can't mint, so we skip rather than throw and
// let the caller's delete attempt proceed (fine for empty buckets).
if (!process.env.CLOUDFLARE_API_TOKEN) {
this.log.warn(
`Skipping R2 wipe for ${bucketName}: no S3 credentials ` +
`(S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY) and no ` +
`CLOUDFLARE_API_TOKEN to mint a bucket-scoped token. A non-empty ` +
`bucket must be emptied manually in the Cloudflare dashboard.`,
);
return;
}
try {
const tokenName = `alepha-teardown-${bucketName}-${Date.now()}`;
const token = await this.api.createR2Token(tokenName, bucketName);
mintedTokenId = token.id;
creds = {
accessKeyId: token.accessKeyId,
secretAccessKey: token.secretAccessKey,
};
} catch (error: any) {
this.log.warn(
`Skipping R2 wipe for ${bucketName}: could not mint an R2 token ` +
`(${String(error.message || "")}). Set S3_ACCESS_KEY_ID / ` +
`S3_SECRET_ACCESS_KEY for reliable teardown.`,
);
return;
}
}
try {
const accountId = await this.api.resolveAccountId();
const jur = ctx.envConfig.jurisdiction;
const host = jur
? `${accountId}.${jur}.r2.cloudflarestorage.com`
: `${accountId}.r2.cloudflarestorage.com`;
const client = new S3mini({
accessKeyId: creds.accessKeyId,
secretAccessKey: creds.secretAccessKey,
region: "auto",
endpoint: `https://${host}/${bucketName}`,
});
// Abort pending multipart uploads. R2 surfaces these as bucket contents
// and they block deletion even after all completed objects are gone.
try {
const mp = await client.listMultipartUploads();
if ("listMultipartUploadsResult" in mp) {
const uploads = mp.listMultipartUploadsResult.uploads ?? [];
for (const upload of uploads) {
const u = upload as unknown as {
Key?: string;
key?: string;
UploadId?: string;
uploadId?: string;
};
const key = u.Key ?? u.key;
const uploadId = u.UploadId ?? u.uploadId;
if (key && uploadId) {
await client.abortMultipartUpload(key, uploadId);
}
}
}
} catch (error: any) {
this.log.debug(
`listMultipartUploads on ${bucketName} failed: ${String(error.message || "")}`,
);
}
// Page through objects and delete in batches of up to 1000 (S3 cap).
let cursor: string | undefined;
let total = 0;
while (true) {
const page = await client.listObjectsPaged(
undefined,
undefined,
1000,
cursor,
);
const objects = page?.objects ?? [];
if (objects.length === 0) {
break;
}
await client.deleteObjects(objects.map((o) => o.Key));
total += objects.length;
cursor = page?.nextContinuationToken;
if (!cursor) {
break;
}
}
if (total > 0) {
this.log.info(`Emptied ${total} object(s) from bucket ${bucketName}.`);
}
} finally {
// Revoke only a token we minted here — env S3 creds are long-lived and
// must not be deleted. Always revoke, even if the wipe failed mid-way.
if (mintedTokenId) {
try {
await this.api.deleteR2Token(mintedTokenId);
} catch (error: any) {
this.log.warn(
`Failed to revoke ephemeral R2 token ${mintedTokenId}: ${String(error.message || "")}`,
);
}
}
}
}
protected async ensureKV(name: string): Promise<string> {
const namespaces = await this.api.listKV();
const existing = namespaces.find((ns) => ns.title === name);
if (existing) {
return existing.id;
}
const created = await this.api.createKV(name);
return created.id;
}
protected async ensureQueue(name: string): Promise<void> {
const queues = await this.api.listQueues();
const existing = queues.find((q) => q.queue_name === name);
if (existing) {
return;
}
await this.api.createQueue(name);
}
/**
* Get the currently active deployment for a worker.
*/
protected async getActiveDeployment(
workerName: string,
): Promise<
{ versionId: string; tag?: string; createdAt?: string } | undefined
> {
const deployments = await this.api.listDeployments(workerName);
// API ordering is not guaranteed across releases — sort explicitly.
const sorted = [...deployments].sort((a, b) =>
b.created_on.localeCompare(a.created_on),
);
const latest = sorted[0];
if (!latest?.versions?.[0]) {
return undefined;
}
const activeVersionId = latest.versions[0].version_id;
const versions = await this.api.listVersions(workerName);
const version = versions.find((v) => v.id === activeVersionId);
return {
versionId: activeVersionId,
tag: version?.annotations?.["workers/tag"],
createdAt: version?.metadata.created_on,
};
}
}
/**
* Stable SHA-256 of the secret set. Keys are sorted so reordering `.env`
* lines does not invalidate the cache. Used as a fingerprint by
* `CloudflareAdapter.secrets` — see the comment block there.
*/
function computeSecretsHash(secrets: Record<string, string>): string {
const sorted = Object.keys(secrets)
.sort()
.map((k) => `${k}=${secrets[k]}`)
.join("\n");
return createHash("sha256").update(sorted).digest("hex");
}