UNPKG

alepha

Version:

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

1,396 lines (1,272 loc) 46.7 kB
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"); }