UNPKG

alepha

Version:

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

663 lines (592 loc) 21.8 kB
import { basename } from "node:path"; import { $inject } from "alepha"; import { KV_DEFAULT_BINDING } from "alepha/cache"; import { SEND_EMAIL_DEFAULT_BINDING } from "alepha/email/cloudflare"; import { QUEUE_DEFAULT_BINDING, QUEUE_DEFAULT_MAX_RETRIES } from "alepha/queue"; import type { CronProvider, WorkerdCronProvider } from "alepha/scheduler"; import { FileSystemProvider } from "alepha/system"; import { ViteUtils } from "../services/ViteUtils.ts"; import { BuildTask, type BuildTaskContext } from "./BuildTask.ts"; // Looked up by class name string (not by class identity) because // BuildCloudflareTask runs in the CLI's Alepha context while ctx.alepha // is the workspace's separate context. Two module graphs = two distinct // `CloudflareEmailProvider` class objects, so the imported reference // here wouldn't match the one the workspace registered. const CLOUDFLARE_EMAIL_PROVIDER_NAME = "CloudflareEmailProvider"; /** * Best-effort Cloudflare zone (registrable domain) for a wildcard Worker route: * strip the leading `*.` and any subdomain labels, keep the last two — e.g. * `*.club.alepha.dev` → `alepha.dev`, `*.alepha.club` → `alepha.club`. Correct * for single-label TLDs (the common case); a multi-label public suffix * (`.co.uk`) or a CF subdomain zone needs an explicit `CLOUDFLARE_ZONE`. */ const deriveZone = (domain: string): string => domain.replace(/^\*\./, "").split(".").slice(-2).join("."); interface WranglerConfig { [key: string]: any; } /** * Build-time snapshot describing what the workspace needs at deploy * time. Written to `dist/manifest.json` alongside `wrangler.jsonc`. * * Lets `alepha platform up --prebuilt` skip the Vite-based * introspection step on the deploy side — and lets Alepha Rocket skip * the workspace's runtime `npm install` because no app source is * booted at deploy time. The manifest captures the bits of primitive * data that the deploy steps (provision, secrets, hooks) need to * know. */ export interface BuildManifest { version: 1; project: string; /** * Default environment when `--env` is omitted at deploy time. * Captured from `platformOptions.default` (defaults to `"production"`). */ defaultEnv: string; /** * Multi-tenancy mode (`none` | `optional` | `required`). Captured from * `platformOptions.tenancy` so the prebuilt deploy side (Rocket) can * validate `--tenant` without re-evaluating `alepha.config.ts`. */ tenancy?: "none" | "optional" | "required"; /** * Resolved `platform({ environments: ... })` map. Captured at build * time from the workspace's `alepha.config.ts` so the deploy side * doesn't need to re-evaluate the config. Each value is the same * `EnvironmentConfig` shape consumed by the orchestrator (adapter, * domain, zone, jurisdiction, accountId). */ environments: Record< string, { adapter: "cloudflare" | "vercel"; domain?: string; zone?: string; jurisdiction?: "eu" | "fedramp"; accountId?: string; } >; resources: { hasDatabase: boolean; hasBucket: boolean; hasKV: boolean; hasQueue: boolean; hasCron: boolean; }; /** * All distinct cron expressions registered by `$scheduler` * primitives. Empty when `hasCron` is false. */ crons: string[]; /** * Cloudflare email binding, captured when the app registers * `CloudflareEmailProvider` at artifact-build time. The prebuilt/manifest * deploy path (Alepha Rocket `--prebuilt`) has no Vite introspection, so it * reads this to re-emit the `send_email` wrangler binding. Absent when the * app doesn't use Cloudflare email. */ email?: { binding: string }; /** * Every env var the app declares via `$env`, captured from * `alepha.dump().env` at build time. The deploy `secrets` step uses this * as the worker-secret allowlist (minus build/binding vars) so CI can * deliver secrets straight from `process.env` without a `.env` file — * `platform.secrets.keys` overrides it when set. Empty when introspection * was unavailable (older artifacts / prebuilt mode). */ env: string[]; } /** * Generate Cloudflare Workers deployment configuration. * * Creates: * - wrangler.jsonc with worker configuration * - main.cloudflare.js entry point for Cloudflare Workers */ export class BuildCloudflareTask extends BuildTask { protected readonly fs = $inject(FileSystemProvider); protected readonly viteUtils = $inject(ViteUtils); protected readonly warningComment = "// This file was automatically generated. DO NOT MODIFY.\n" + "// Changes to this file will be lost when the code is regenerated.\n"; async run(ctx: BuildTaskContext): Promise<void> { if (ctx.options.target !== "cloudflare") { return; } const distDir = ctx.options.output?.dist ?? "dist"; await ctx.run({ name: "generate deploy config (cloudflare)", handler: async () => { await this.generateCloudflare(ctx, distDir); }, }); } protected async generateCloudflare( ctx: BuildTaskContext, distDir: string, ): Promise<void> { const root = ctx.root; // Slugify the dir basename — wrangler rejects names that aren't // `^[a-z0-9-]+$` (no uppercase, dots, underscores, spaces, etc.). // Without this, running `alepha build -t cloudflare` in a dir like // `My App` or `club-0.0.2` produces an unusable `wrangler.jsonc`. const name = basename(root) .toLowerCase() .replace(/[^a-z0-9-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 63); const hasAssets = await this.fs.exists( this.fs.join(root, distDir, "public"), ); const wrangler: WranglerConfig = { name, main: "./main.cloudflare.js", compatibility_flags: ["nodejs_compat"], compatibility_date: "2025-11-17", no_bundle: true, rules: [ { type: "ESModule", globs: ["index.js", "server/*.js"], }, ], ...ctx.options.cloudflare?.config, }; if (hasAssets) { wrangler.assets ??= { directory: "./public", binding: "ASSETS", }; } wrangler.observability ??= { enabled: true, head_sampling_rate: 1, }; this.enhanceDomain(wrangler); this.enhanceServices(wrangler); this.enhanceCron(ctx, wrangler); this.enhanceDatabase(wrangler); this.enhanceR2(wrangler); this.enhanceKV(wrangler); this.enhanceQueue(wrangler); this.enhanceEmail(ctx, wrangler); await this.fs.writeFile( this.fs.join(root, distDir, "wrangler.jsonc"), JSON.stringify(wrangler, null, 2), ); // Only write a fresh manifest when we discovered it from a booted // Alepha instance. In manifest mode (ctx.manifest != null) we're // re-emitting the same data we just read — skip to avoid a redundant // write and to keep the original manifest as the canonical record. if (!ctx.manifest) { await this.writeManifest(ctx, root, distDir, name); } await this.writeWorkerEntryPoint(root, distDir); } /** * Write `dist/manifest.json` — a build-time snapshot of everything * downstream tooling needs to know about the app without re-booting * it. Used by `alepha platform up --prebuilt` (and Alepha Rocket) so * the deploy path can skip the Vite-based introspection step and the * workspace's runtime npm install. */ protected async writeManifest( ctx: BuildTaskContext, root: string, distDir: string, name: string, ): Promise<void> { // Discover the same primitive shapes the enhance* methods read. // Errors are silently swallowed — an absent primitive class just // means the app doesn't use that resource. let hasDatabase = false; let hasBucket = false; let hasKV = false; let hasQueue = false; let crons: string[] = []; try { const repo = ctx.alepha.inject("RepositoryProvider") as { getRepositories?: () => unknown[]; }; hasDatabase = (repo.getRepositories?.() ?? []).length > 0; } catch {} try { hasBucket = ctx.alepha.primitives("$bucket").length > 0; } catch {} try { // Only count $cache primitives without an explicit `provider` // option — those fall back to KV on workerd. Explicit memory / // Redis / Postgres providers opt out of KV provisioning. hasKV = ctx.alepha .primitives("cache") .filter( (p) => (p as { options?: { provider?: unknown } }).options?.provider == null, ).length > 0; } catch {} try { hasQueue = ctx.alepha.primitives("$queue").length > 0; } catch {} try { const cronProvider = ctx.alepha.inject("CronProvider") as { getCronJobs?: () => Array<{ expression: string }>; }; crons = [ ...new Set( (cronProvider.getCronJobs?.() ?? []).map((c) => c.expression), ), ]; } catch {} // platformOptions come from the CLI's Alepha instance (where // alepha.config.ts ran during the configure hook). BuildCommand // reads them up there and threads them via ctx — ctx.alepha here // is the WORKSPACE's Vite-booted Alepha, which never saw the // platform options. const defaultEnv = ctx.platformOptions?.default ?? "production"; const environments = (ctx.platformOptions?.environments ?? {}) as BuildManifest["environments"]; // Every declared `$env` key. dump() force-instantiates the graph (no // start/ready hooks), so this is the full env surface — used by the // deploy `secrets` step as the worker-secret allowlist. let env: string[] = []; try { env = Object.keys(ctx.alepha.dump().env).sort(); } catch {} // Capture the CF email binding so manifest-mode deploys (Rocket) can // re-emit `send_email` — `enhanceEmail` can't introspect there. let email: BuildManifest["email"]; try { ctx.alepha.inject(CLOUDFLARE_EMAIL_PROVIDER_NAME); email = { binding: SEND_EMAIL_DEFAULT_BINDING }; } catch {} const manifest: BuildManifest = { version: 1, project: name, defaultEnv, tenancy: ctx.platformOptions?.tenancy, environments, resources: { hasDatabase, hasBucket, hasKV, hasQueue, hasCron: crons.length > 0, }, crons, email, env, }; await this.fs.writeFile( this.fs.join(root, distDir, "manifest.json"), JSON.stringify(manifest, null, 2), ); } /** Worker-to-worker service bindings, from CLOUDFLARE_SERVICES (JSON). */ protected enhanceServices(wrangler: WranglerConfig): void { const raw = process.env.CLOUDFLARE_SERVICES; if (!raw) { return; } const services = JSON.parse(raw) as Array<{ binding: string; service: string; }>; if (services.length > 0) { (wrangler as { services?: unknown }).services = services; } } protected enhanceDomain(wrangler: WranglerConfig): void { const domain = process.env.CLOUDFLARE_DOMAIN; if (!domain) { return; } if (domain.includes("*")) { // A wildcard is a Worker *Route* (not a Custom Domain), and the CF API // keys routes by zone. Default the zone to the registrable domain — the // last two labels of the wildcard host (`*.club.alepha.dev` → `alepha.dev`, // `*.alepha.club` → `alepha.club`). Set CLOUDFLARE_ZONE explicitly only to // override (a subdomain zone, or a multi-label public suffix like `.co.uk` // where "last two labels" is wrong). const zone = process.env.CLOUDFLARE_ZONE || deriveZone(domain); wrangler.routes = [ { pattern: domain.endsWith("/*") ? domain : `${domain}/*`, zone_name: zone, }, ]; return; } // An explicit CLOUDFLARE_ZONE forces a zone *Route* for a non-wildcard // host too. Needed when the host is ALSO covered by another Worker's // wildcard route on the same zone: Cloudflare evaluates Routes before // Custom Domains, but among routes the most specific pattern wins — so // `app.club.alepha.dev/*` beats the pooled `*.club.alepha.dev/*`, while a // Custom Domain on that host would lose to the wildcard route entirely. if (process.env.CLOUDFLARE_ZONE) { wrangler.routes = [ { pattern: `${domain}/*`, zone_name: process.env.CLOUDFLARE_ZONE, }, ]; return; } wrangler.routes = [ { pattern: domain, custom_domain: true, }, ]; } protected enhanceCron(ctx: BuildTaskContext, wrangler: WranglerConfig): void { const cronExpressions = ctx.manifest ? ctx.manifest.crons : this.discoverCrons(ctx); if (cronExpressions.length === 0) { return; } wrangler.triggers ??= {}; wrangler.triggers.crons = cronExpressions; } protected discoverCrons(ctx: BuildTaskContext): string[] { if (ctx.alepha.primitives("scheduler").length === 0) { return []; } let cronProvider: CronProvider | undefined; try { cronProvider = ctx.alepha.inject("CronProvider") as WorkerdCronProvider; } catch {} const crons = cronProvider?.getCronJobs(); if (!crons || crons.length === 0) { return []; } return [...new Set(crons.map((c) => c.expression))]; } protected enhanceDatabase(wrangler: WranglerConfig): void { if (process.env.HYPERDRIVE_ID) { this.enhanceHyperdrive(wrangler); return; } this.enhanceD1(wrangler); } protected static readonly D1_BINDING = "DB"; protected enhanceD1(wrangler: WranglerConfig): void { const url = process.env.DATABASE_URL; if (!url?.startsWith("d1:")) { return; } const [dbName, id] = url.replace("d1://", "").replace("d1:", "").split(":"); const binding = BuildCloudflareTask.D1_BINDING; // No `jurisdiction` here: unlike r2_buckets, the wrangler D1 binding schema // has no jurisdiction field (it warns on the unexpected key). D1 data // residency is fixed when the database is created — see CloudflareApi — // and the binding just references it by `database_id`. wrangler.d1_databases = wrangler.d1_databases || []; wrangler.d1_databases.push({ binding, database_name: dbName, database_id: id, }); wrangler.vars ??= {}; wrangler.vars.DATABASE_URL = `d1://${binding}`; } protected enhanceHyperdrive(wrangler: WranglerConfig): void { const hyperdriveId = process.env.HYPERDRIVE_ID; if (!hyperdriveId) { return; } const binding = "HYPERDRIVE"; wrangler.hyperdrive = wrangler.hyperdrive || []; wrangler.hyperdrive.push({ binding, id: hyperdriveId, }); wrangler.vars ??= {}; wrangler.vars.DATABASE_URL = `hyperdrive://${binding}`; if (process.env.POSTGRES_SCHEMA) { wrangler.vars.POSTGRES_SCHEMA = process.env.POSTGRES_SCHEMA; } } protected enhanceR2(wrangler: WranglerConfig): void { const bucketName = process.env.R2_BUCKET_NAME; if (!bucketName) { return; } const jurisdiction = process.env.CLOUDFLARE_JURISDICTION; wrangler.r2_buckets = wrangler.r2_buckets || []; wrangler.r2_buckets.push({ binding: bucketName, bucket_name: bucketName, ...(jurisdiction ? { jurisdiction } : {}), }); wrangler.vars ??= {}; wrangler.vars.R2_BUCKET_NAME = bucketName; } protected enhanceKV(wrangler: WranglerConfig): void { const kvName = process.env.CLOUDFLARE_KV_NAME; if (!kvName) { return; } const kvId = process.env.CLOUDFLARE_KV_ID; wrangler.kv_namespaces = wrangler.kv_namespaces || []; wrangler.kv_namespaces.push({ binding: KV_DEFAULT_BINDING, id: kvId ?? "", }); } protected enhanceQueue(wrangler: WranglerConfig): void { const queueName = process.env.CLOUDFLARE_QUEUE_NAME; if (!queueName) { return; } wrangler.queues ??= {}; wrangler.queues.producers = wrangler.queues.producers || []; wrangler.queues.producers.push({ binding: QUEUE_DEFAULT_BINDING, queue: queueName, }); // The worker's queue handler calls `msg.retry()` on any throw. Cloudflare // only gives a failing message somewhere to land if the consumer declares a // `dead_letter_queue` — otherwise it burns `max_retries` and DISCARDS the // message, with no record and no signal. CF creates the DLQ on demand, so a // derived default is safe. const maxRetries = Number(process.env.CLOUDFLARE_QUEUE_MAX_RETRIES); wrangler.queues.consumers = wrangler.queues.consumers || []; wrangler.queues.consumers.push({ queue: queueName, dead_letter_queue: process.env.CLOUDFLARE_QUEUE_DLQ_NAME || `${queueName}-dlq`, max_retries: Number.isSafeInteger(maxRetries) ? maxRetries : QUEUE_DEFAULT_MAX_RETRIES, }); } protected enhanceEmail( ctx: BuildTaskContext, wrangler: WranglerConfig, ): void { // Resolve the CF email binding from whichever source this build path has: // - manifest/prebuilt mode (Alepha Rocket `--prebuilt`): no app boot, so // read the binding captured into the manifest at artifact-build time. // Without this the deploy silently drops `send_email` and the worker // boots with email inert (binding not found). // - full Vite introspection (`ctx.alepha`, no manifest): probe for the // registered CloudflareEmailProvider. let binding: string | undefined; if (ctx.manifest) { binding = ctx.manifest.email?.binding; } else if (ctx.alepha) { try { ctx.alepha.inject(CLOUDFLARE_EMAIL_PROVIDER_NAME); binding = SEND_EMAIL_DEFAULT_BINDING; } catch { // app doesn't use CloudflareEmailProvider — nothing to emit } } if (!binding) { return; } wrangler.send_email = wrangler.send_email || []; if (wrangler.send_email.some((b: { name: string }) => b.name === binding)) { return; } // NOTE: do NOT set `destination_address` here. On a Cloudflare // `send_email` binding, `destination_address` is a *recipient* allow-list // lock (the worker may then only send TO that one address) — it is not the // sender. Setting it to `EMAIL_FROM` (the sender) broke all outbound mail: // a bare address locked delivery to that single recipient ("email to … not // allowed"), and a display-name form like `Lore <noreply@…>` is a malformed // destination value that Cloudflare rejects with "internal error". The // sender goes in the message `from` field (see CloudflareEmailProvider.send); // leaving the binding unrestricted lets the worker send to any verified // destination. wrangler.send_email.push({ name: binding }); } protected async writeWorkerEntryPoint( root: string, distDir: string, ): Promise<void> { const workerCode = ` import "./index.js"; // Run an invocation inside an Alepha fork carrying THIS invocation's // \`executionCtx.waitUntil\`, so background work (notably $job direct dispatch) // can keep the isolate alive past the response. // // It must be the async context, never the shared store: one isolate serves // concurrent invocations, so a store slot would let request B overwrite // request A's handle — A's background work would then call B's already-returned // context ("waitUntil after response") and be silently dropped. const withExecutionContext = (executionCtx, fn) => { const waitUntil = executionCtx && typeof executionCtx.waitUntil === "function" ? (p) => executionCtx.waitUntil(p) : undefined; return __alepha.context.run(fn, { "cloudflare.waitUntil": waitUntil }); }; // Bind the per-invocation Worker \`env\`: keep the full binding (D1, R2, KV, …) // in the store for providers, and lift its string values (secrets/vars like // PUBLIC_URL) into \`alepha.env\` so \`$env\` resolves them at runtime. const bindEnv = (env) => { __alepha.set("cloudflare.env", env); __alepha.loadEnv(env); }; export default { fetch: async (request, env, executionCtx) => { const ctx = { req: request, res: undefined }; bindEnv(env); try { await __alepha.start(); } catch (err) { __alepha.log.error("Failed to start Alepha for fetch event", err); return new Response("Internal Server Error", { status: 500 }); } await withExecutionContext(executionCtx, () => __alepha.events.emit("web:request", ctx), ); return ctx.res; }, scheduled: async (event, env, executionCtx) => { bindEnv(env); try { await __alepha.start(); } catch (err) { __alepha.log.error("Failed to start Alepha for scheduled event", err); throw err; } await withExecutionContext(executionCtx, () => __alepha.events.emit("cloudflare:scheduled", { cron: event.cron, scheduledTime: event.scheduledTime, }), ); }, queue: async (batch, env, executionCtx) => { bindEnv(env); try { await __alepha.start(); } catch (err) { __alepha.log.error("Failed to start Alepha for queue event", err); throw err; } await withExecutionContext(executionCtx, async () => { for (const msg of batch.messages) { try { await __alepha.events.emit("cloudflare:queue", msg.body); msg.ack(); } catch (e) { msg.retry(); } } }); }, }; `.trim(); await this.fs.writeFile( this.fs.join(root, distDir, "main.cloudflare.js"), `${this.warningComment}\n${workerCode}`.trim(), ); } }