diff --git a/bun.lock b/bun.lock index d905b7f95..292d26606 100644 --- a/bun.lock +++ b/bun.lock @@ -491,6 +491,25 @@ "vite": "catalog:", }, }, + "examples/cloudflare-aurora-hyperdrive": { + "name": "cloudflare-aurora-hyperdrive", + "version": "0.0.0", + "dependencies": { + "@cloudflare/workers-types": "catalog:", + "@distilled.cloud/aws": "catalog:", + "@effect/platform-bun": "catalog:", + "@effect/platform-node": "catalog:", + "@effect/sql-pg": "catalog:", + "alchemy": "workspace:*", + "drizzle-orm": "1.0.0-rc.1", + "effect": "catalog:", + "pg": "^8.13.0", + }, + "devDependencies": { + "@types/pg": "^8.11.0", + "drizzle-kit": "1.0.0-rc.1", + }, + }, "examples/cloudflare-dev": { "name": "cloudflare-dev", "version": "0.0.0", @@ -2496,6 +2515,8 @@ "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "cloudflare-aurora-hyperdrive": ["cloudflare-aurora-hyperdrive@workspace:examples/cloudflare-aurora-hyperdrive"], + "cloudflare-dev": ["cloudflare-dev@workspace:examples/cloudflare-dev"], "cloudflare-email": ["cloudflare-email@workspace:examples/cloudflare-email"], diff --git a/examples/cloudflare-aurora-hyperdrive/README.md b/examples/cloudflare-aurora-hyperdrive/README.md new file mode 100644 index 000000000..df72604e0 --- /dev/null +++ b/examples/cloudflare-aurora-hyperdrive/README.md @@ -0,0 +1,113 @@ +# cloudflare-aurora-hyperdrive + +A Cloudflare Worker reaching an AWS RDS **Aurora** database through +**Hyperdrive**, with Drizzle migrations applied at deploy time via the RDS Data +API. Aurora lives in a VPC, so there are two ways to let Hyperdrive reach it — +documented here as alternative paths with their trade-offs. + +The example is split into an **infra stack** (AWS Aurora + the Hyperdrive +config) and an **app stack** (the Cloudflare Worker). The Worker binds the +Hyperdrive **by reference** (`Cloudflare.Hyperdrive.ref(id, { stack })`), so the +app stack — and the Worker bundle — stay Cloudflare-only; no AWS code or +credentials leak into the Worker. Deploy the infra stack first, then the app +stack. + +``` +src/schema.ts Drizzle schema (Users/Posts) +src/handler.ts shared CRUD handler (Drizzle over Hyperdrive) +src/Aurora.ts shared: VPC + Aurora + Drizzle.Schema + RDS.Schema (Data API migrations) +src/names.ts shared stack names + Hyperdrive logical id +src/PublicDb.ts public path: Aurora (public) + Hyperdrive (public origin) +src/PublicApi.ts public path: Worker binding Hyperdrive.ref(...) +public-infra.ts public path: infra stack +public-app.ts public path: app stack +test/integ.test.ts deploys infra → app, drives CRUD over HTTP, destroys +``` + +## Path A — public endpoint + firewall (implemented) + +The Aurora cluster is publicly accessible; its security group allows the +database port from Cloudflare, and Hyperdrive connects to the public endpoint +directly. + +```ts +Cloudflare.Hyperdrive("AppHyperdrive", { + origin: { + scheme: "postgres", + host: cluster.endpoint, // public writer endpoint + port: cluster.port, + database: "app", + user: "app", + password, + }, +}); +``` + +- **Pros:** simplest setup; lowest latency (Hyperdrive connects straight to RDS). +- **Cons:** the database has a public endpoint. Mitigate by restricting the + security-group ingress to [Hyperdrive's published egress IP ranges](https://developers.cloudflare.com/hyperdrive/configuration/firewall-and-networking-settings/) + rather than `0.0.0.0/0`, and require TLS. +- **Best for:** when a (firewalled) public endpoint is acceptable. + +Run it: + +```sh +DB_PASSWORD= bun test # deploy infra → app → CRUD → destroy +``` + +## Path B — Cloudflare Tunnel + Access (private, recommended for production) + +Keep Aurora fully private (no public endpoint). Run `cloudflared` inside the VPC +(e.g. as an ECS Fargate service) connected to a Cloudflare Tunnel that +TCP-routes a hostname to the Aurora endpoint, secure the hostname with a +Cloudflare Access self-hosted app + a Service-Auth policy + a service token, and +point Hyperdrive at an **Access-protected origin**: + +```ts +Cloudflare.Hyperdrive("AppHyperdrive", { + origin: { + scheme: "postgres", + host: tunnelHostname, // no port for an Access origin + database: "app", + user: "app", + password, + accessClientId: token.clientId, + accessClientSecret: token.clientSecret, + }, + dev: { /* public localhost origin for `alchemy dev` */ }, +}); +``` + +- **Pros:** the database never leaves the VPC; strongest isolation. +- **Cons:** you run and pay for `cloudflared` (ECS), and add a tunnel hop + (latency + connector cold-start). +- **Best for:** production databases that must stay private. + +All the alchemy primitives exist (`Cloudflare.Tunnel`, `DnsRecord`, +`Access.Application/Policy/ServiceToken`, `Hyperdrive`'s Access origin, and +`AWS.ECS.*` for `cloudflared`). See Cloudflare's guide: +[Connect to a private database using Tunnel](https://developers.cloudflare.com/hyperdrive/configuration/connect-to-private-database/). + +### Running the Tunnel path + +```sh +DB_PASSWORD= CLOUDFLARE_TEST_TUNNEL=1 bun test # gated; deploys cloudflared + tunnel + access +``` + +The Access hostname lives on a Cloudflare zone (`CLOUDFLARE_ZONE_NAME`, +default `alchemy-test-2.us`). That zone **must serve an active edge +certificate** covering the apex and `*.zone` — Hyperdrive validates the +connection at create time, and with no edge cert it fails with +`TLS handshake failed [HANDSHAKE_FAILURE_ON_CLIENT_HELLO]`. + +Cloudflare's Universal SSL provides this by default. `bun nuke` can leave a +test zone with the setting reported enabled but **zero certificate packs** +(no edge cert). Re-order it once with: + +```sh +ZONE= ALCHEMY_PROFILE=testing bun scripts/enable-universal-ssl.ts +``` + +> Cloudflare's newest option, [Workers VPC](https://developers.cloudflare.com/hyperdrive/configuration/connect-to-private-database-vpc/), +> is a cleaner future path; alchemy has a `Cloudflare.VpcService` resource, but +> Hyperdrive's origin can't yet reference it, so it's not wired here. diff --git a/examples/cloudflare-aurora-hyperdrive/cloudflared/Dockerfile b/examples/cloudflare-aurora-hyperdrive/cloudflared/Dockerfile new file mode 100644 index 000000000..d40385e34 --- /dev/null +++ b/examples/cloudflare-aurora-hyperdrive/cloudflared/Dockerfile @@ -0,0 +1,5 @@ +# cloudflared connector for the Tunnel path. The official image's ENTRYPOINT is +# `cloudflared --no-autoupdate`; `tunnel run` reads the connector token from the +# TUNNEL_TOKEN environment variable injected by the ECS task definition. +FROM --platform=linux/amd64 cloudflare/cloudflared:latest +CMD ["tunnel", "run"] diff --git a/examples/cloudflare-aurora-hyperdrive/migrations/20260618014227_migration/migration.sql b/examples/cloudflare-aurora-hyperdrive/migrations/20260618014227_migration/migration.sql new file mode 100644 index 000000000..733880035 --- /dev/null +++ b/examples/cloudflare-aurora-hyperdrive/migrations/20260618014227_migration/migration.sql @@ -0,0 +1,18 @@ +CREATE TABLE "posts" ( + "id" serial PRIMARY KEY, + "user_id" integer NOT NULL, + "title" text NOT NULL, + "body" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +--> statement-breakpoint +CREATE TABLE "users" ( + "id" serial PRIMARY KEY, + "email" text NOT NULL UNIQUE, + "name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); + +--> statement-breakpoint +ALTER TABLE "posts" ADD CONSTRAINT "posts_user_id_users_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE; diff --git a/examples/cloudflare-aurora-hyperdrive/migrations/20260618014227_migration/snapshot.json b/examples/cloudflare-aurora-hyperdrive/migrations/20260618014227_migration/snapshot.json new file mode 100644 index 000000000..72d54a984 --- /dev/null +++ b/examples/cloudflare-aurora-hyperdrive/migrations/20260618014227_migration/snapshot.json @@ -0,0 +1,176 @@ +{ + "dialect": "postgres", + "id": "6b7086d4-87b5-40db-9764-6b989233aa5b", + "prevIds": ["00000000-0000-0000-0000-000000000000"], + "version": "8", + "ddl": [ + { + "isRlsEnabled": false, + "name": "posts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "users", + "entityType": "tables", + "schema": "public" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "body", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "posts" + }, + { + "type": "serial", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "users" + }, + { + "nameExplicit": false, + "columns": ["user_id"], + "schemaTo": "public", + "tableTo": "users", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "posts_user_id_users_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "posts" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "posts_pkey", + "schema": "public", + "table": "posts", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "users_pkey", + "schema": "public", + "table": "users", + "entityType": "pks" + }, + { + "nameExplicit": false, + "columns": ["email"], + "nullsNotDistinct": false, + "name": "users_email_key", + "schema": "public", + "table": "users", + "entityType": "uniques" + } + ], + "renames": [] +} diff --git a/examples/cloudflare-aurora-hyperdrive/package.json b/examples/cloudflare-aurora-hyperdrive/package.json new file mode 100644 index 000000000..0eecdfa82 --- /dev/null +++ b/examples/cloudflare-aurora-hyperdrive/package.json @@ -0,0 +1,30 @@ +{ + "name": "cloudflare-aurora-hyperdrive", + "version": "0.0.0", + "private": true, + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/alchemy-run/alchemy-effect.git", + "directory": "examples/cloudflare-aurora-hyperdrive" + }, + "type": "module", + "scripts": { + "test": "bun test" + }, + "dependencies": { + "@cloudflare/workers-types": "catalog:", + "@distilled.cloud/aws": "catalog:", + "@effect/platform-bun": "catalog:", + "@effect/platform-node": "catalog:", + "@effect/sql-pg": "catalog:", + "alchemy": "workspace:*", + "drizzle-orm": "1.0.0-rc.1", + "effect": "catalog:", + "pg": "^8.13.0" + }, + "devDependencies": { + "@types/pg": "^8.11.0", + "drizzle-kit": "1.0.0-rc.1" + } +} diff --git a/examples/cloudflare-aurora-hyperdrive/public-app.ts b/examples/cloudflare-aurora-hyperdrive/public-app.ts new file mode 100644 index 000000000..2fe16eb02 --- /dev/null +++ b/examples/cloudflare-aurora-hyperdrive/public-app.ts @@ -0,0 +1,22 @@ +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import PublicApi from "./src/PublicApi.ts"; +import { PUBLIC_APP_STACK } from "./src/names.ts"; + +/** + * App stack for the public-firewall path: a Cloudflare Worker that binds the + * Hyperdrive from the infra stack by reference and serves user CRUD via Drizzle. + * Cloudflare-only — deploy after `public-infra.ts`. + */ +export default Alchemy.Stack( + PUBLIC_APP_STACK, + { + providers: Cloudflare.providers(), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const api = yield* PublicApi; + return { url: api.url.as() }; + }), +); diff --git a/examples/cloudflare-aurora-hyperdrive/public-infra.ts b/examples/cloudflare-aurora-hyperdrive/public-infra.ts new file mode 100644 index 000000000..1d178dbb7 --- /dev/null +++ b/examples/cloudflare-aurora-hyperdrive/public-infra.ts @@ -0,0 +1,30 @@ +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Drizzle from "alchemy/Drizzle"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { PublicHyperdrive } from "./src/PublicDb.ts"; +import { PUBLIC_INFRA_STACK } from "./src/names.ts"; + +/** + * Infra stack for the public-firewall path: a publicly-accessible Aurora + * cluster (migrations applied via the RDS Data API) fronted by a Hyperdrive + * whose origin is the cluster's public endpoint. Deploy this first; the app + * stack's Worker references the Hyperdrive created here. + */ +export default Alchemy.Stack( + PUBLIC_INFRA_STACK, + { + providers: Layer.mergeAll( + AWS.providers(), + Cloudflare.providers(), + Drizzle.providers(), + ), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const hyperdrive = yield* PublicHyperdrive; + return { hyperdriveId: hyperdrive.hyperdriveId }; + }), +); diff --git a/examples/cloudflare-aurora-hyperdrive/src/Aurora.ts b/examples/cloudflare-aurora-hyperdrive/src/Aurora.ts new file mode 100644 index 000000000..f1f56e414 --- /dev/null +++ b/examples/cloudflare-aurora-hyperdrive/src/Aurora.ts @@ -0,0 +1,93 @@ +import * as AWS from "alchemy/AWS"; +import * as Drizzle from "alchemy/Drizzle"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; + +export interface AuroraOptions { + /** + * `true` — public subnets + `publiclyAccessible` instances (public-firewall path). + * `false` — private subnets, no public endpoint (Tunnel + Access path). + */ + publiclyAccessible: boolean; + /** Ingress rules for the cluster's security group (path-specific). */ + dbIngress: AWS.EC2.SecurityGroupRuleData[]; + /** NAT strategy — `"single"` when the VPC needs egress (cloudflared). */ + nat?: "none" | "single"; +} + +/** + * Shared Aurora bring-up used by both Hyperdrive paths: a VPC, a cluster with a + * **known** master password (from `DB_PASSWORD`, so a Hyperdrive origin can use + * it without a deploy-time secret read), and Drizzle migrations applied through + * the RDS Data API. + * + * Returns the cluster + resolved credentials so the caller can build the + * appropriate Hyperdrive origin (public or Access-protected). + */ +export const makeAurora = (options: AuroraOptions) => + Effect.gen(function* () { + const password = yield* Config.redacted("DB_PASSWORD"); + + const network = yield* AWS.EC2.Network("Network", { + cidrBlock: "10.0.0.0/16", + // Explicit AZs avoid an ec2:DescribeAvailabilityZones call (the layer + // re-runs in any bound runtime); see the RDS example's note. + availabilityZones: ["us-west-2a", "us-west-2b"], + nat: options.nat ?? "none", + }); + + const dbSecurityGroup = yield* AWS.EC2.SecurityGroup( + "DatabaseSecurityGroup", + { + vpcId: network.vpcId, + description: "Aurora cluster for the Hyperdrive example", + ingress: options.dbIngress, + }, + ); + + const schema = yield* Drizzle.Schema("AppSchema", { + schema: "./src/schema.ts", + out: "./migrations", + }); + + const db = yield* AWS.RDS.Aurora("AppDb", { + databaseName: "app", + subnetIds: options.publiclyAccessible + ? network.publicSubnetIds + : network.privateSubnetIds, + securityGroupIds: [dbSecurityGroup.groupId], + secret: { + username: "app", + // Known password so both Hyperdrive origins can authenticate. + secretString: Redacted.make( + JSON.stringify({ + username: "app", + password: Redacted.value(password), + }), + ), + }, + instance: { + dbInstanceClass: "db.serverless", + publiclyAccessible: options.publiclyAccessible, + }, + }); + + const dbSchema = yield* AWS.RDS.Schema("AppDbSchema", { + resourceArn: db.cluster.dbClusterArn, + secretArn: db.secret.secretArn, + database: "app", + migrationsDir: schema.out, + }); + + // `dbSchema` only completes once the writer is reachable and migrations are + // applied — downstream (the Hyperdrive) depends on it so it isn't created + // (and connection-validated by Cloudflare) before the database is ready. + return { + network, + dbSecurityGroup, + cluster: db.cluster, + dbSchema, + password, + }; + }); diff --git a/examples/cloudflare-aurora-hyperdrive/src/CloudflaredTask.ts b/examples/cloudflare-aurora-hyperdrive/src/CloudflaredTask.ts new file mode 100644 index 000000000..7956b8612 --- /dev/null +++ b/examples/cloudflare-aurora-hyperdrive/src/CloudflaredTask.ts @@ -0,0 +1,34 @@ +import * as AWS from "alchemy/AWS"; +import type * as Output from "alchemy/Output"; +import * as Effect from "effect/Effect"; + +// `FROM cloudflare/cloudflared` — the official image's ENTRYPOINT runs the +// connector; `tunnel run` reads the token from TUNNEL_TOKEN. The bundled `main` +// (the trivial cloudflared-entry) is ignored by this Dockerfile. +const DOCKERFILE = `FROM cloudflare/cloudflared:latest\nCMD ["tunnel", "run"]\n`; + +// The image is built for the deploy host's architecture; run Fargate on the +// matching platform so it can pull it (arm64 Macs → Graviton, amd64 → X86_64). +const cpuArchitecture = process.arch === "arm64" ? "ARM64" : "X86_64"; + +const entry = new URL("./cloudflared-entry.ts", import.meta.url).pathname; + +/** + * ECS Fargate task definition for the cloudflared connector. A factory (not a + * class) so the Tunnel's connector token can be injected as `TUNNEL_TOKEN`. + */ +export default (options: { tunnelToken: Output.Output }) => + AWS.ECS.Task( + "CloudflaredTask", + { + main: entry, + docker: { dockerfile: DOCKERFILE }, + env: { TUNNEL_TOKEN: options.tunnelToken }, + cpu: 256, + memory: 512, + runtimePlatform: { cpuArchitecture, operatingSystemFamily: "LINUX" }, + }, + // The container is the cloudflared image (see Dockerfile); the bundled + // entrypoint is never executed, so the runtime body is a no-op. + Effect.void, + ); diff --git a/examples/cloudflare-aurora-hyperdrive/src/PublicApi.ts b/examples/cloudflare-aurora-hyperdrive/src/PublicApi.ts new file mode 100644 index 000000000..a6fa870de --- /dev/null +++ b/examples/cloudflare-aurora-hyperdrive/src/PublicApi.ts @@ -0,0 +1,24 @@ +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Drizzle from "alchemy/Drizzle"; +import * as Effect from "effect/Effect"; +import { makeFetch, relations } from "./handler.ts"; +import { HYPERDRIVE_ID, PUBLIC_INFRA_STACK } from "./names.ts"; + +/** + * Worker for the public-firewall path. It binds the Hyperdrive **by reference** + * into the infra stack (`Hyperdrive.ref(id, { stack })`), so the app stack — + * and the Worker bundle — only ever touch Cloudflare. The infra stack (Aurora + + * Hyperdrive) is deployed first; this stack references its already-deployed + * Hyperdrive. + */ +export default class PublicApi extends Cloudflare.Worker()( + "PublicApi", + { main: import.meta.filename }, + Effect.gen(function* () { + const conn = yield* Cloudflare.Hyperdrive.bind( + Cloudflare.Hyperdrive.ref(HYPERDRIVE_ID, { stack: PUBLIC_INFRA_STACK }), + ); + const db = yield* Drizzle.postgres(conn.connectionString, { relations }); + return { fetch: makeFetch(db) }; + }).pipe(Effect.provide(Cloudflare.HyperdriveBindingLive)), +) {} diff --git a/examples/cloudflare-aurora-hyperdrive/src/PublicDb.ts b/examples/cloudflare-aurora-hyperdrive/src/PublicDb.ts new file mode 100644 index 000000000..5a973f81d --- /dev/null +++ b/examples/cloudflare-aurora-hyperdrive/src/PublicDb.ts @@ -0,0 +1,45 @@ +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Output from "alchemy/Output"; +import * as Effect from "effect/Effect"; +import { makeAurora } from "./Aurora.ts"; +import { HYPERDRIVE_ID } from "./names.ts"; + +/** + * Public-firewall path: a publicly-accessible Aurora cluster whose security + * group allows the database port from Cloudflare. In production, restrict the + * ingress CIDR to Hyperdrive's published egress ranges rather than 0.0.0.0/0. + */ +export const PublicAurora = makeAurora({ + publiclyAccessible: true, + nat: "none", + dbIngress: [ + { + ipProtocol: "tcp", + fromPort: 5432, + toPort: 5432, + cidrIpv4: "0.0.0.0/0", + description: + "Postgres from Hyperdrive (restrict to Cloudflare egress ranges in production)", + }, + ], +}); + +/** Hyperdrive pointed straight at the cluster's public endpoint. */ +export const PublicHyperdrive = Effect.gen(function* () { + const { cluster, dbSchema, password } = yield* PublicAurora; + return yield* Cloudflare.Hyperdrive(HYPERDRIVE_ID, { + origin: { + scheme: "postgres", + // Gate the host on `dbSchema` so the Hyperdrive (which Cloudflare + // connection-validates at create) isn't created until the writer is + // reachable and migrations have applied. + host: Output.all(cluster.endpoint, dbSchema.database).pipe( + Output.map(([endpoint]) => endpoint as string), + ), + port: cluster.port.as(), + database: "app", + user: "app", + password, + }, + }); +}); diff --git a/examples/cloudflare-aurora-hyperdrive/src/TunnelApi.ts b/examples/cloudflare-aurora-hyperdrive/src/TunnelApi.ts new file mode 100644 index 000000000..6e8b7c722 --- /dev/null +++ b/examples/cloudflare-aurora-hyperdrive/src/TunnelApi.ts @@ -0,0 +1,24 @@ +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Drizzle from "alchemy/Drizzle"; +import * as Effect from "effect/Effect"; +import { makeFetch, relations } from "./handler.ts"; +import { TUNNEL_HYPERDRIVE_ID, TUNNEL_INFRA_STACK } from "./names.ts"; + +/** + * Worker for the Tunnel + Access path. Binds the Access-protected Hyperdrive + * from the tunnel infra stack by reference — the database is private, reached + * via cloudflared + Cloudflare Access — and serves the same CRUD handler. + */ +export default class TunnelApi extends Cloudflare.Worker()( + "TunnelApi", + { main: import.meta.filename }, + Effect.gen(function* () { + const conn = yield* Cloudflare.Hyperdrive.bind( + Cloudflare.Hyperdrive.ref(TUNNEL_HYPERDRIVE_ID, { + stack: TUNNEL_INFRA_STACK, + }), + ); + const db = yield* Drizzle.postgres(conn.connectionString, { relations }); + return { fetch: makeFetch(db) }; + }).pipe(Effect.provide(Cloudflare.HyperdriveBindingLive)), +) {} diff --git a/examples/cloudflare-aurora-hyperdrive/src/TunnelDb.ts b/examples/cloudflare-aurora-hyperdrive/src/TunnelDb.ts new file mode 100644 index 000000000..630639da2 --- /dev/null +++ b/examples/cloudflare-aurora-hyperdrive/src/TunnelDb.ts @@ -0,0 +1,207 @@ +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Drizzle from "alchemy/Drizzle"; +import * as Output from "alchemy/Output"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import CloudflaredTask from "./CloudflaredTask.ts"; +import { TUNNEL_HYPERDRIVE_ID } from "./names.ts"; + +/** + * Tunnel + Access (private) path. Aurora stays in private subnets with no public + * endpoint; a `cloudflared` connector runs in the VPC (ECS Fargate) and a + * Cloudflare Tunnel TCP-routes a hostname to the cluster. Cloudflare Access + * (self-hosted app + Service-Auth policy + service token) guards the hostname, + * and Hyperdrive uses an Access-protected origin. + */ +export const TunnelInfra = Effect.gen(function* () { + const { stage } = yield* Alchemy.Stack; + const password = yield* Config.redacted("DB_PASSWORD"); + const zoneName = yield* Config.string("CLOUDFLARE_ZONE_NAME").pipe( + Config.withDefault("alchemy-test-2.us"), + ); + const { accountId } = yield* yield* Cloudflare.CloudflareEnvironment; + const zone = yield* Cloudflare.findZoneByName({ + accountId, + name: zoneName, + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie); + if (!zone) { + return yield* Effect.die(`Cloudflare zone "${zoneName}" not found`); + } + const zoneId = zone.id; + const hostname = `aurora-tunnel-${stage}.${zoneName}`; + + // ── AWS network (private subnets + a NAT for cloudflared egress) ───────── + const network = yield* AWS.EC2.Network("Network", { + cidrBlock: "10.0.0.0/16", + availabilityZones: ["us-west-2a", "us-west-2b"], + nat: "single", + }); + + const cloudflaredSecurityGroup = yield* AWS.EC2.SecurityGroup( + "CloudflaredSecurityGroup", + { + vpcId: network.vpcId, + description: "cloudflared connector", + egress: [ + { + ipProtocol: "tcp", + fromPort: 443, + toPort: 443, + cidrIpv4: "0.0.0.0/0", + description: "HTTPS to Cloudflare edge", + }, + { + ipProtocol: "udp", + fromPort: 7844, + toPort: 7844, + cidrIpv4: "0.0.0.0/0", + description: "Cloudflare Tunnel QUIC", + }, + { + ipProtocol: "tcp", + fromPort: 7844, + toPort: 7844, + cidrIpv4: "0.0.0.0/0", + description: "Cloudflare Tunnel HTTP2 fallback", + }, + { + ipProtocol: "tcp", + fromPort: 5432, + toPort: 5432, + cidrIpv4: "10.0.0.0/16", + description: "Postgres to Aurora", + }, + ], + }, + ); + + const databaseSecurityGroup = yield* AWS.EC2.SecurityGroup( + "DatabaseSecurityGroup", + { + vpcId: network.vpcId, + description: "Aurora cluster (private)", + ingress: [ + { + ipProtocol: "tcp", + fromPort: 5432, + toPort: 5432, + referencedGroupId: cloudflaredSecurityGroup.groupId, + description: "Postgres from cloudflared", + }, + ], + }, + ); + + // ── Aurora (private) + Data API migrations ────────────────────────────── + const schema = yield* Drizzle.Schema("AppSchema", { + schema: "./src/schema.ts", + out: "./migrations", + }); + + const db = yield* AWS.RDS.Aurora("AppDb", { + databaseName: "app", + subnetIds: network.privateSubnetIds, + securityGroupIds: [databaseSecurityGroup.groupId], + secret: { + username: "app", + secretString: Redacted.make( + JSON.stringify({ username: "app", password: Redacted.value(password) }), + ), + }, + instance: { dbInstanceClass: "db.serverless", publiclyAccessible: false }, + }); + + const dbSchema = yield* AWS.RDS.Schema("AppDbSchema", { + resourceArn: db.cluster.dbClusterArn, + secretArn: db.secret.secretArn, + database: "app", + migrationsDir: schema.out, + }); + + // ── Cloudflare Tunnel → TCP to the cluster ────────────────────────────── + const tunnel = yield* Cloudflare.Tunnel("AppTunnel", { + configSrc: "cloudflare", + ingress: [ + { + hostname, + service: Output.interpolate`tcp://${db.cluster.endpoint}:5432`, + }, + { service: "http_status:404" }, + ], + }); + + yield* Cloudflare.DnsRecord("TunnelCname", { + zoneId, + name: hostname, + type: "CNAME", + content: Output.interpolate`${tunnel.tunnelId}.cfargotunnel.com`, + proxied: true, + }); + + // ── Cloudflare Access (Service-Auth) guarding the hostname ────────────── + const token = yield* Cloudflare.AccessServiceToken("HyperdriveToken", { + name: `aurora-hyperdrive-${stage}`, + }); + + const policy = yield* Cloudflare.AccessPolicy("AllowHyperdriveToken", { + name: `aurora-hyperdrive-${stage}`, + decision: "non_identity", + include: [{ serviceToken: { tokenId: token.serviceTokenId } }], + }); + + const app = yield* Cloudflare.AccessApplication("DbAccess", { + type: "self_hosted", + name: `aurora-hyperdrive-${stage}`, + domain: hostname, + policies: [policy.policyId], + }); + + // ── cloudflared connector (ECS Fargate) ───────────────────────────────── + const cluster = yield* AWS.ECS.Cluster("CloudflaredCluster", {}); + const task = yield* CloudflaredTask({ + tunnelToken: Output.map(tunnel.token, Redacted.value), + }); + const service = yield* AWS.ECS.Service("CloudflaredService", { + cluster, + task, + vpcId: network.vpcId, + subnets: network.privateSubnetIds, + securityGroups: [cloudflaredSecurityGroup.groupId], + assignPublicIp: false, + desiredCount: 1, + }); + + // ── Hyperdrive (Access origin) ────────────────────────────────────────── + // Gate creation on the connector + migrations + DNS + Access, so Cloudflare's + // connect-time validation runs only once the whole path is live. + const hyperdrive = yield* Cloudflare.Hyperdrive(TUNNEL_HYPERDRIVE_ID, { + origin: { + scheme: "postgres", + host: Output.all( + service.serviceArn, + dbSchema.database, + app.applicationId, + ).pipe(Output.map(() => hostname)), + database: "app", + user: "app", + password, + accessClientId: Output.map(token.clientId, (id) => Redacted.make(id)), + accessClientSecret: Output.map(token.clientSecret, (s) => s!), + }, + // Access origins can't be reached in `alchemy dev`; supply a local origin. + dev: { + scheme: "postgres", + host: "localhost", + port: 5432, + database: "app", + user: "app", + password, + }, + }); + + return hyperdrive; +}); diff --git a/examples/cloudflare-aurora-hyperdrive/src/cloudflared-entry.ts b/examples/cloudflare-aurora-hyperdrive/src/cloudflared-entry.ts new file mode 100644 index 000000000..5e95efb65 --- /dev/null +++ b/examples/cloudflare-aurora-hyperdrive/src/cloudflared-entry.ts @@ -0,0 +1,7 @@ +/** + * Placeholder entrypoint for the cloudflared ECS task. `AWS.ECS.Task` requires + * a `main`, but the task's Docker image is built from `cloudflared/Dockerfile` + * (`FROM cloudflare/cloudflared`), whose ENTRYPOINT runs the connector — this + * bundled module is never executed. + */ +export default {}; diff --git a/examples/cloudflare-aurora-hyperdrive/src/handler.ts b/examples/cloudflare-aurora-hyperdrive/src/handler.ts new file mode 100644 index 000000000..dc1adfc38 --- /dev/null +++ b/examples/cloudflare-aurora-hyperdrive/src/handler.ts @@ -0,0 +1,82 @@ +import { eq } from "drizzle-orm"; +import type { EffectPgDatabase } from "drizzle-orm/effect-postgres"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { relations, Users } from "./schema.ts"; + +/** + * Shared user-CRUD HTTP handler, driven by a Drizzle/Postgres database that the + * Worker opens over Hyperdrive. Identical for both the public-firewall and + * Tunnel+Access paths — only how Hyperdrive reaches Aurora differs. + */ +export const makeFetch = (db: EffectPgDatabase) => + Effect.gen(function* () { + const request = yield* HttpServerRequest; + const pathname = new URL(request.originalUrl).pathname; + + switch (request.method) { + case "GET": { + if (pathname === "/") { + const users = yield* db.select().from(Users); + return yield* HttpServerResponse.json({ users }); + } + const id = Number(pathname.split("/").pop()); + if (Number.isNaN(id)) { + return yield* HttpServerResponse.json( + { error: "Invalid user ID" }, + { status: 400 }, + ); + } + const user = yield* db.query.Users.findFirst({ + where: { id }, + with: { posts: true }, + }); + return yield* HttpServerResponse.json({ user }); + } + case "POST": { + const user = yield* db + .insert(Users) + .values({ name: crypto.randomUUID(), email: crypto.randomUUID() }) + .returning(); + return yield* HttpServerResponse.json({ user }); + } + case "DELETE": { + const id = Number(pathname.split("/").pop()); + if (Number.isNaN(id)) { + return yield* HttpServerResponse.json( + { error: "Invalid user ID" }, + { status: 400 }, + ); + } + const [user] = yield* db + .delete(Users) + .where(eq(Users.id, id)) + .returning(); + return yield* HttpServerResponse.json({ user }); + } + default: { + return yield* HttpServerResponse.json( + { error: "Method not allowed" }, + { status: 405 }, + ); + } + } + }).pipe( + Effect.catch((cause: unknown) => { + const peel = (e: any): any => (e?.cause ? peel(e.cause) : e); + const root = peel(cause); + return HttpServerResponse.json( + { + ok: false, + error: String(cause), + rootError: root?.message ?? String(root), + rootCode: root?.code, + }, + { status: 500 }, + ); + }), + ); + +// re-export so worker files import schema bits from one place +export { relations, Users }; diff --git a/examples/cloudflare-aurora-hyperdrive/src/names.ts b/examples/cloudflare-aurora-hyperdrive/src/names.ts new file mode 100644 index 000000000..42f977f8a --- /dev/null +++ b/examples/cloudflare-aurora-hyperdrive/src/names.ts @@ -0,0 +1,13 @@ +/** + * Shared stack names + the Hyperdrive logical id. The app stack's Worker binds + * the Hyperdrive by reference into the infra stack, so both sides must agree on + * these strings. Keeping them in a string-only module lets the Worker reference + * the infra resources without importing any AWS code into its bundle. + */ +export const PUBLIC_INFRA_STACK = "CfAuroraHyperdrivePublicInfra"; +export const PUBLIC_APP_STACK = "CfAuroraHyperdrivePublicApp"; +export const HYPERDRIVE_ID = "AppHyperdrive"; + +export const TUNNEL_INFRA_STACK = "CfAuroraHyperdriveTunnelInfra"; +export const TUNNEL_APP_STACK = "CfAuroraHyperdriveTunnelApp"; +export const TUNNEL_HYPERDRIVE_ID = "AppTunnelHyperdrive"; diff --git a/examples/cloudflare-aurora-hyperdrive/src/schema.ts b/examples/cloudflare-aurora-hyperdrive/src/schema.ts new file mode 100644 index 000000000..7c3633eed --- /dev/null +++ b/examples/cloudflare-aurora-hyperdrive/src/schema.ts @@ -0,0 +1,37 @@ +import { defineRelations } from "drizzle-orm"; +import { integer, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core"; + +export const Users = pgTable("users", { + id: serial("id").primaryKey(), + email: text("email").notNull().unique(), + name: text("name").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); +export type User = typeof Users.$inferSelect; + +export const Posts = pgTable("posts", { + id: serial("id").primaryKey(), + userId: integer("user_id") + .notNull() + .references(() => Users.id, { onDelete: "cascade" }), + title: text("title").notNull(), + body: text("body").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); +export type Post = typeof Posts.$inferSelect; + +export const relations = defineRelations({ Users, Posts }, (t) => ({ + Users: { + posts: t.many.Posts(), + }, + Posts: { + user: t.one.Users({ + from: t.Posts.userId, + to: t.Users.id, + }), + }, +})); diff --git a/examples/cloudflare-aurora-hyperdrive/test/integ.test.ts b/examples/cloudflare-aurora-hyperdrive/test/integ.test.ts new file mode 100644 index 000000000..917c3651f --- /dev/null +++ b/examples/cloudflare-aurora-hyperdrive/test/integ.test.ts @@ -0,0 +1,119 @@ +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Drizzle from "alchemy/Drizzle"; +import * as Test from "alchemy/Test/Bun"; +import { expect } from "bun:test"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import PublicAppStack from "../public-app.ts"; +import PublicInfraStack from "../public-infra.ts"; +import TunnelAppStack from "../tunnel-app.ts"; +import TunnelInfraStack from "../tunnel-infra.ts"; +import type { Post, User } from "../src/schema.ts"; + +// Aurora bring-up + writer readiness + migrations + Hyperdrive (and, for the +// tunnel path, cloudflared + tunnel/access propagation) dominate the wall clock. +const DEPLOY_TIMEOUT = 2_400_000; + +const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ + providers: Layer.mergeAll( + AWS.providers(), + Cloudflare.providers(), + Drizzle.providers(), + ), + state: Alchemy.localState(), +}); + +const getReady = (url: string) => + HttpClient.get(url).pipe( + Effect.filterOrFail( + (r) => r.status === 200, + () => new Error("worker not serving yet"), + ), + Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 60 }), + ); + +// Exercises the CRUD routes against a deployed Worker → Hyperdrive → Aurora. +const runCrud = (url: string) => + Effect.gen(function* () { + expect(url).toBeString(); + const baseUrl = url.replace(/\/+$/, ""); + + const initial = yield* getReady(baseUrl); + expect(initial.status).toBe(200); + const initialBody = (yield* initial.json) as unknown as { users: User[] }; + expect(Array.isArray(initialBody.users)).toBe(true); + + const created = yield* HttpClient.execute(HttpClientRequest.post(baseUrl)); + expect(created.status).toBe(200); + const createdBody = (yield* created.json) as unknown as { user: User[] }; + const [user] = createdBody.user; + expect(user.id).toBeNumber(); + + const read = yield* HttpClient.get(`${baseUrl}/${user.id}`); + const readBody = (yield* read.json) as unknown as { + user: User & { posts: Post[] }; + }; + expect(readBody.user).toMatchObject({ id: user.id, posts: [] }); + + const deleted = yield* HttpClient.execute( + HttpClientRequest.delete(`${baseUrl}/${user.id}`), + ); + expect(deleted.status).toBe(200); + + const final = yield* HttpClient.get(baseUrl); + const finalBody = (yield* final.json) as unknown as { users: User[] }; + expect(finalBody.users.some((u) => u.id === user.id)).toBe(false); + }); + +// ── Public-firewall path ─────────────────────────────────────────────────── +const publicApp = beforeAll(deploy(PublicInfraStack), { + timeout: DEPLOY_TIMEOUT, +}); +const publicAppHandle = beforeAll(deploy(PublicAppStack), { + timeout: DEPLOY_TIMEOUT, +}); +void publicApp; +afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(PublicAppStack), { + timeout: DEPLOY_TIMEOUT, +}); +afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(PublicInfraStack), { + timeout: DEPLOY_TIMEOUT, +}); + +test( + "CRUD over Hyperdrive → public Aurora", + Effect.gen(function* () { + const { url } = yield* publicAppHandle; + yield* runCrud(url); + }), + { timeout: 120_000 }, +); + +// ── Tunnel + Access path (needs a Cloudflare zone for the Access hostname) ─── +// Opt-in: it provisions cloudflared (ECS) + a tunnel + access and is slow. +if (process.env.CLOUDFLARE_TEST_TUNNEL) { + beforeAll(deploy(TunnelInfraStack), { timeout: DEPLOY_TIMEOUT }); + const tunnelAppHandle = beforeAll(deploy(TunnelAppStack), { + timeout: DEPLOY_TIMEOUT, + }); + afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(TunnelAppStack), { + timeout: DEPLOY_TIMEOUT, + }); + afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(TunnelInfraStack), { + timeout: DEPLOY_TIMEOUT, + }); + + test( + "CRUD over Hyperdrive (Tunnel + Access) → private Aurora", + Effect.gen(function* () { + const { url } = yield* tunnelAppHandle; + yield* runCrud(url); + }), + { timeout: 120_000 }, + ); +} diff --git a/examples/cloudflare-aurora-hyperdrive/tsconfig.json b/examples/cloudflare-aurora-hyperdrive/tsconfig.json new file mode 100644 index 000000000..d203c54b5 --- /dev/null +++ b/examples/cloudflare-aurora-hyperdrive/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [ + "public-infra.ts", + "public-app.ts", + "tunnel-infra.ts", + "tunnel-app.ts", + "src/**/*.ts", + "test/**/*.ts" + ], + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "module": "Preserve", + "moduleResolution": "Bundler", + "customConditions": ["bun"], + "target": "ESNext" + }, + "references": [ + { + "path": "../../packages/alchemy/tsconfig.json" + } + ] +} diff --git a/examples/cloudflare-aurora-hyperdrive/tunnel-app.ts b/examples/cloudflare-aurora-hyperdrive/tunnel-app.ts new file mode 100644 index 000000000..3e6aca16e --- /dev/null +++ b/examples/cloudflare-aurora-hyperdrive/tunnel-app.ts @@ -0,0 +1,22 @@ +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import TunnelApi from "./src/TunnelApi.ts"; +import { TUNNEL_APP_STACK } from "./src/names.ts"; + +/** + * App stack for the Tunnel + Access path: a Cloudflare Worker binding the + * Access-protected Hyperdrive from the tunnel infra stack. Cloudflare-only; + * deploy after `tunnel-infra.ts`. + */ +export default Alchemy.Stack( + TUNNEL_APP_STACK, + { + providers: Cloudflare.providers(), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const api = yield* TunnelApi; + return { url: api.url.as() }; + }), +); diff --git a/examples/cloudflare-aurora-hyperdrive/tunnel-infra.ts b/examples/cloudflare-aurora-hyperdrive/tunnel-infra.ts new file mode 100644 index 000000000..caa362787 --- /dev/null +++ b/examples/cloudflare-aurora-hyperdrive/tunnel-infra.ts @@ -0,0 +1,30 @@ +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Drizzle from "alchemy/Drizzle"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { TunnelInfra } from "./src/TunnelDb.ts"; +import { TUNNEL_INFRA_STACK } from "./src/names.ts"; + +/** + * Infra stack for the Tunnel + Access path: a private Aurora cluster, a + * cloudflared connector (ECS Fargate), a Cloudflare Tunnel + Access guarding a + * hostname, and an Access-protected Hyperdrive. Requires `CLOUDFLARE_ZONE_ID` + * and `CLOUDFLARE_ZONE_NAME` for the Access hostname. Deploy before the app. + */ +export default Alchemy.Stack( + TUNNEL_INFRA_STACK, + { + providers: Layer.mergeAll( + AWS.providers(), + Cloudflare.providers(), + Drizzle.providers(), + ), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const hyperdrive = yield* TunnelInfra; + return { hyperdriveId: hyperdrive.hyperdriveId }; + }), +); diff --git a/packages/alchemy/src/Output.ts b/packages/alchemy/src/Output.ts index 26514cd2c..3828aea05 100644 --- a/packages/alchemy/src/Output.ts +++ b/packages/alchemy/src/Output.ts @@ -381,6 +381,12 @@ export class RefExpr extends BaseExpr { super(); return proxy(this); } + // A ref's logical id is its `resourceId`. Exposing it lets consumers that + // read `resource.LogicalId` (e.g. binding metadata) work on refs too, instead + // of getting an unresolved attribute Output that decodes to `undefined`. + get LogicalId(): string { + return this.resourceId; + } [inspect](): string { return `ref(${this.resourceId}, { stack: ${this.stack}, stage: ${this.stage} })`; } diff --git a/packages/alchemy/src/Resource.ts b/packages/alchemy/src/Resource.ts index 4dd19b39f..ef52ada60 100644 --- a/packages/alchemy/src/Resource.ts +++ b/packages/alchemy/src/Resource.ts @@ -214,6 +214,16 @@ export function Resource( .join(", "); } + // `Resource.ref(id)` resolves to a RefExpr — key the binding by + // its logical id (the binding's other fields read the resource's + // attributes as deploy-resolved Outputs). This lets a Worker + // bind a resource owned by another stack/provider (e.g. an + // AWS-backed Hyperdrive) without pulling that provider's + // requirements into the Worker. + if (Output.isRefExpr(arg)) { + return arg.resourceId; + } + if ( arg && (typeof arg === "object" || typeof arg === "function") diff --git a/scripts/enable-universal-ssl.ts b/scripts/enable-universal-ssl.ts new file mode 100644 index 000000000..c03aa4283 --- /dev/null +++ b/scripts/enable-universal-ssl.ts @@ -0,0 +1,130 @@ +#!/usr/bin/env bun + +// @ts-nocheck +/** + * Ensure a Cloudflare zone serves a working edge certificate (default: + * alchemy-test-2.us). + * + * `bun nuke` can leave the standing test zone without an active Universal + * SSL certificate: a UniversalSsl test toggles the setting, then state is + * wiped before the "restore to initial" delete runs, so Cloudflare ends up + * with the setting reported `enabled: true` but **zero certificate packs**. + * The zone then serves no edge cert — every hostname fails TLS, and + * Hyperdrive's connect-time validation errors with + * `HANDSHAKE_FAILURE_ON_CLIENT_HELLO`. + * + * This script makes the zone whole again: + * 1. enable Universal SSL if disabled, and + * 2. if it is enabled but no active cert pack exists, toggle it off→on to + * force Cloudflare to re-order the universal certificate. + * It then polls until an active pack covering the apex + `*.zone` appears. + * + * ZONE=alchemy-test-2.us ALCHEMY_PROFILE=testing bun scripts/enable-universal-ssl.ts + * + * Note: the SSL settings endpoint needs the "Zone SSL and Certificates" + * token scope — the `testing` profile has it; a minimal `default` profile + * may not. + */ +import * as ssl from "@distilled.cloud/cloudflare/ssl"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import * as Layer from "effect/Layer"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import { AuthProviders } from "../packages/alchemy/src/Auth/AuthProvider.ts"; +import { CredentialsStoreLive } from "../packages/alchemy/src/Auth/Credentials.ts"; +import { ProfileLive } from "../packages/alchemy/src/Auth/Profile.ts"; +import { CloudflareAuth } from "../packages/alchemy/src/Cloudflare/Auth/AuthProvider.ts"; +import { + CloudflareEnvironment, + fromProfile, +} from "../packages/alchemy/src/Cloudflare/CloudflareEnvironment.ts"; +import { fromAuthProvider } from "../packages/alchemy/src/Cloudflare/Credentials.ts"; +import { findZoneByName } from "../packages/alchemy/src/Cloudflare/Zone/lookup.ts"; +import { + PlatformServices, + runMain, +} from "../packages/alchemy/src/Util/PlatformServices.ts"; + +const ZONE = process.env.ZONE ?? "alchemy-test-2.us"; + +// The "edit setting" endpoint has its own per-zone rate limit; ride it out. +const patch = (zoneId: string, enabled: boolean) => + ssl.patchUniversalSetting({ zoneId, enabled }).pipe( + Effect.retry({ + while: (e) => e._tag === "TooManyRequests", + schedule: Schedule.spaced("20 seconds"), + times: 6, + }), + ); + +const activePacks = (zoneId: string) => + ssl + .listCertificatePacks({ zoneId }) + .pipe( + Effect.map((p) => + (p.result ?? []).filter((pack) => pack.status === "active"), + ), + ); + +const program = Effect.gen(function* () { + const { accountId } = yield* yield* CloudflareEnvironment; + const zone = yield* findZoneByName({ accountId, name: ZONE }); + if (!zone) return yield* Effect.die(`zone "${ZONE}" not found`); + const zoneId = zone.id; + yield* Console.log(`zone ${ZONE} → ${zoneId}`); + + const before = yield* ssl.getUniversalSetting({ zoneId }); + const packs = yield* activePacks(zoneId); + yield* Console.log( + `universal SSL enabled=${before.enabled}, active cert packs=${packs.length}`, + ); + + if (before.enabled === true && packs.length > 0) { + yield* Console.log("zone already serves an active edge cert — nothing to do"); + return; + } + + if (before.enabled !== true) { + yield* Console.log("enabling Universal SSL…"); + yield* patch(zoneId, true); + } else { + // Enabled but no cert pack — toggle off→on to force a re-order. + yield* Console.log("enabled but no cert — toggling off→on to re-order…"); + yield* patch(zoneId, false); + yield* Effect.sleep("8 seconds"); + yield* patch(zoneId, true); + } + + const final = yield* activePacks(zoneId).pipe( + Effect.tap((p) => Console.log(`waiting for active cert pack… (${p.length})`)), + Effect.repeat({ + schedule: Schedule.spaced("15 seconds"), + until: (p) => p.length > 0, + times: 12, + }), + ); + if (final.length === 0) { + return yield* Effect.die("no active cert pack after waiting"); + } + yield* Console.log( + `active edge cert: ${final[0].hosts?.join(", ")} (issuer ${final[0].issuer})`, + ); +}); + +const authLayer = Layer.provideMerge( + CloudflareAuth, + Layer.succeed(AuthProviders, {}), +); +const profile = Layer.mergeAll( + Layer.provide(ProfileLive, PlatformServices), + Layer.provide(CredentialsStoreLive, PlatformServices), +); +const cloudflare = Layer.mergeAll(fromAuthProvider(), fromProfile()).pipe( + Layer.provide(authLayer), + Layer.provide(profile), +); + +runMain( + program.pipe(Effect.provide(Layer.mergeAll(cloudflare, FetchHttpClient.layer))), +); diff --git a/tsconfig.json b/tsconfig.json index 9a12de463..13c2b8154 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -72,6 +72,9 @@ { "path": "./examples/aws-lambda-rds-aurora-drizzle/tsconfig.json" }, + { + "path": "./examples/cloudflare-aurora-hyperdrive/tsconfig.json" + }, { "path": "./examples/aws-static-site/tsconfig.json" },