From 35ac2ac1003694739ffc595b56d5cc3f83701e15 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 9 Nov 2026 20:21:56 +0000 Subject: [PATCH] chore(skills): refresh locked skills and cover Tegami and Oxc Update the locked skills, switch Alchemy to wyattjoh/skills, and add oxlint, oxfmt, and a Tegami skill for the tools that had none. Co-authored-by: Jonathan Beckman --- .agents/skills/alchemy/SKILL.md | 973 +-- .agents/skills/alchemy/package.json | 14 + .agents/skills/alchemy/reference/examples.md | 466 -- .../reference/provider-implementation.md | 483 -- .../alchemy/reference/upstream-concepts.md | 100 - .../skills/alchemy/references/acme/INDEX.md | 14 + .../alchemy/references/acme/_overview.md | 66 + .../acme/certificate-authorities.md | 159 + .../alchemy/references/acme/dns-validation.md | 209 + .../references/acme/getting-started.md | 194 + .../skills/alchemy/references/acme/renewal.md | 138 + .../skills/alchemy/references/acme/runtime.md | 316 + .../references/acme/troubleshooting.md | 132 + .../references/acme/using-certificates.md | 189 + .../skills/alchemy/references/apis/INDEX.md | 10 + .../alchemy/references/apis/_overview.md | 87 + .../alchemy/references/apis/effect-http.md | 245 + .../alchemy/references/apis/effect-rpc.md | 200 + .../alchemy/references/apis/schemaless.md | 174 + .../skills/alchemy/references/aws/INDEX.md | 115 + .../alchemy/references/aws/_overview.md | 123 + .../alchemy/references/aws/ai/bedrock.md | 217 + .../references/aws/apis/api-gateway.md | 131 + .../references/aws/apis/effect-http-api.md | 485 ++ .../alchemy/references/aws/apis/effect-rpc.md | 426 ++ .../references/aws/apis/schemaless-rpc.md | 139 + .../aws/compute/choosing-a-runtime.md | 144 + .../alchemy/references/aws/compute/ec2.md | 172 + .../alchemy/references/aws/compute/ecs.md | 319 + .../alchemy/references/aws/compute/eks.md | 446 ++ .../references/aws/compute/hyperpod.md | 279 + .../alchemy/references/aws/compute/lambda.md | 343 + .../references/aws/compute/microvms.md | 246 + .../references/aws/data/drizzle-aurora.md | 218 + .../references/aws/data/drizzle-dsql.md | 203 + .../alchemy/references/aws/data/dynamodb.md | 201 + .../skills/alchemy/references/aws/data/rds.md | 337 + .../skills/alchemy/references/aws/data/s3.md | 311 + .../alchemy/references/aws/email/receiving.md | 268 + .../alchemy/references/aws/email/sending.md | 410 ++ .../alchemy/references/aws/frontend/astro.md | 216 + .../references/aws/frontend/foldkit.md | 239 + .../full-stack-tanstack-rpc-drizzle.md | 401 ++ .../alchemy/references/aws/frontend/nextjs.md | 167 + .../alchemy/references/aws/frontend/nuxt.md | 203 + .../alchemy/references/aws/frontend/octane.md | 146 + .../references/aws/frontend/react-router.md | 306 + .../references/aws/frontend/solidstart.md | 239 + .../references/aws/frontend/static-site.md | 225 + .../references/aws/frontend/sveltekit.md | 177 + .../references/aws/frontend/tanstack-start.md | 302 + .../alchemy/references/aws/frontend/vinext.md | 136 + .../references/aws/frontend/vite-spa.md | 294 + .../alchemy/references/aws/frontend/vite.md | 310 + .../alchemy/references/aws/frontend/vue.md | 240 + .../alchemy/references/aws/frontend/waku.md | 168 + .../references/aws/frontend/websites.md | 127 + .../references/aws/local-development.md | 255 + .../aws/messaging/dynamodb-streams.md | 194 + .../references/aws/messaging/eventbridge.md | 309 + .../references/aws/messaging/kinesis.md | 300 + .../references/aws/messaging/s3-events.md | 201 + .../alchemy/references/aws/messaging/sns.md | 310 + .../alchemy/references/aws/messaging/sqs.md | 378 ++ .../references/aws/networking/_overview.md | 260 + .../aws/networking/custom-domains.md | 195 + .../aws/observability/cloudwatch.md | 185 + .../references/aws/security/secrets-env.md | 220 + .../skills/alchemy/references/aws/setup.md | 104 + .../alchemy/references/aws/tutorial/part-1.md | 219 + .../alchemy/references/aws/tutorial/part-2.md | 406 ++ .../alchemy/references/aws/tutorial/part-3.md | 240 + .../alchemy/references/aws/tutorial/part-4.md | 174 + .../alchemy/references/aws/tutorial/part-5.md | 448 ++ .../skills/alchemy/references/axiom/INDEX.md | 22 + .../alchemy/references/axiom/_overview.md | 101 + .../alchemy/references/axiom/data/ingest.md | 181 + .../references/axiom/guides/alerting.md | 208 + .../references/axiom/guides/annotations.md | 110 + .../references/axiom/guides/dashboards.md | 243 + .../skills/alchemy/references/axiom/setup.md | 32 + .../alchemy/references/better-auth/INDEX.md | 59 + .../references/better-auth/_overview.md | 49 + .../better-auth/databases/_overview.md | 49 + .../better-auth/databases/aurora-data-api.md | 120 + .../better-auth/databases/cloudflare-d1.md | 96 + .../databases/cloudflare-hyperdrive.md | 121 + .../better-auth/databases/drizzle.md | 193 + .../better-auth/databases/memory.md | 111 + .../references/better-auth/databases/mysql.md | 134 + .../references/better-auth/databases/neon.md | 125 + .../better-auth/databases/postgres.md | 131 + .../better-auth/databases/sqlite.md | 129 + .../better-auth/guides/configuration.md | 123 + .../better-auth/guides/http-api-middleware.md | 186 + .../better-auth/guides/migrations.md | 125 + .../better-auth/guides/secondary-storage.md | 165 + .../sign-in-providers/_overview.md | 47 + .../sign-in-providers/custom-oauth.md | 361 + .../sign-in-providers/email-password.md | 549 ++ .../better-auth/sign-in-providers/github.md | 344 + .../better-auth/sign-in-providers/google.md | 360 + .../sign-in-providers/microsoft.md | 344 + .../references/better-auth/tutorial/part-1.md | 153 + .../references/better-auth/tutorial/part-2.md | 116 + .../references/better-auth/tutorial/part-3.md | 168 + .../references/better-auth/tutorial/part-4.md | 213 + .../references/better-auth/tutorial/part-5.md | 146 + .../references/better-auth/tutorial/part-6.md | 121 + .../better-auth/upgrades/from-1-6-to-1-7.md | 154 + .../skills/alchemy/references/cli/INDEX.md | 20 + .../alchemy/references/cli/_overview.md | 90 + .../references/cli/adopting-resources.md | 96 + .agents/skills/alchemy/references/cli/aws.md | 80 + .../alchemy/references/cli/cloudflare.md | 190 + .../skills/alchemy/references/cli/deploy.md | 99 + .../skills/alchemy/references/cli/destroy.md | 77 + .agents/skills/alchemy/references/cli/dev.md | 55 + .../skills/alchemy/references/cli/drift.md | 52 + .../references/cli/inspecting-state.md | 136 + .agents/skills/alchemy/references/cli/logs.md | 55 + .agents/skills/alchemy/references/cli/nuke.md | 110 + .agents/skills/alchemy/references/cli/plan.md | 74 + .../skills/alchemy/references/cli/profile.md | 349 + .../skills/alchemy/references/cli/state.md | 110 + .../alchemy/references/cloudflare/INDEX.md | 139 + .../references/cloudflare/_overview.md | 109 + .../references/cloudflare/ai/ai-gateway.md | 282 + .../references/cloudflare/ai/ai-search.md | 323 + .../references/cloudflare/ai/effect-ai.md | 274 + .../references/cloudflare/ai/release-agent.md | 186 + .../references/cloudflare/ai/vectorize.md | 200 + .../references/cloudflare/ai/workers-ai.md | 211 + .../cloudflare/apis/effect-http-api.md | 629 ++ .../references/cloudflare/apis/effect-rpc.md | 927 +++ .../cloudflare/apis/schemaless-rpc.md | 311 + .../cloudflare/compute/add-a-workflow.md | 482 ++ .../cloudflare/compute/browser-rendering.md | 142 + .../references/cloudflare/compute/cache.md | 115 + .../cloudflare/compute/containers.md | 620 ++ .../compute/cross-worker-durable-object.md | 501 ++ .../cloudflare/compute/durable-objects.md | 699 ++ .../cloudflare/compute/gradual-deployments.md | 365 ++ .../compute/hibernatable-websockets.md | 683 ++ .../references/cloudflare/compute/previews.md | 87 + .../cloudflare/compute/python-workers.md | 251 + .../cloudflare/compute/rate-limiting.md | 138 + .../cloudflare/compute/run-a-container.md | 487 ++ .../cloudflare/compute/worker-loader.md | 173 + .../compute/workers-for-platforms.md | 157 + .../references/cloudflare/compute/workers.md | 653 ++ .../cloudflare/compute/workflows.md | 430 ++ .../references/cloudflare/data/artifacts.md | 156 + .../data/branch-from-shared-database.md | 378 ++ .../references/cloudflare/data/d1-drizzle.md | 141 + .../alchemy/references/cloudflare/data/d1.md | 192 + .../references/cloudflare/data/drizzle.md | 291 + .../references/cloudflare/data/hyperdrive.md | 639 ++ .../alchemy/references/cloudflare/data/kv.md | 203 + .../references/cloudflare/data/prisma.md | 181 + .../cloudflare/data/r2-presigned-urls.md | 205 + .../alchemy/references/cloudflare/data/r2.md | 200 + .../cloudflare/data/shared-database.md | 265 + .../references/cloudflare/email/_overview.md | 237 + .../cloudflare/email/email-worker.md | 175 + .../cloudflare/email/send-and-receive.md | 262 + .../references/cloudflare/frontend/astro.md | 258 + .../references/cloudflare/frontend/foldkit.md | 226 + .../cloudflare/frontend/frontends.md | 109 + .../full-stack-tanstack-rpc-drizzle.md | 551 ++ .../references/cloudflare/frontend/nextjs.md | 270 + .../references/cloudflare/frontend/nuxt.md | 242 + .../references/cloudflare/frontend/octane.md | 219 + .../cloudflare/frontend/react-router.md | 247 + .../cloudflare/frontend/solidstart.md | 179 + .../cloudflare/frontend/static-site.md | 175 + .../cloudflare/frontend/sveltekit.md | 216 + .../cloudflare/frontend/tanstack-start.md | 207 + .../references/cloudflare/frontend/vinext.md | 137 + .../cloudflare/frontend/vite-spa.md | 666 ++ .../references/cloudflare/frontend/vite.md | 290 + .../references/cloudflare/frontend/vue.md | 131 + .../references/cloudflare/frontend/waku.md | 205 + .../cloudflare/local-development.md | 227 + .../references/cloudflare/messaging/cron.md | 289 + .../cloudflare/messaging/github-events.md | 260 + .../references/cloudflare/messaging/queues.md | 431 ++ .../cloudflare/networking/custom-domains.md | 286 + .../cloudflare/networking/domains.md | 188 + .../cloudflare/networking/tunnel.md | 132 + .../observability/analytics-engine.md | 138 + .../observability/axiom-observability.md | 272 + .../observability/workers-tracing.md | 161 + .../references/cloudflare/security/access.md | 231 + .../cloudflare/security/secrets-env.md | 227 + .../cloudflare/security/secrets-store.md | 245 + .../cloudflare/security/turnstile.md | 117 + .../alchemy/references/cloudflare/setup.md | 104 + .../references/cloudflare/tutorial/part-1.md | 268 + .../references/cloudflare/tutorial/part-2.md | 386 ++ .../references/cloudflare/tutorial/part-3.md | 241 + .../references/cloudflare/tutorial/part-4.md | 114 + .../references/cloudflare/tutorial/part-5.md | 494 ++ .../references/cloudflare/tutorial/part-6.md | 279 + .../alchemy/references/command/INDEX.md | 9 + .../alchemy/references/command/_overview.md | 97 + .../alchemy/references/command/dev-servers.md | 82 + .../alchemy/references/command/memoization.md | 132 + .../skills/alchemy/references/docker/INDEX.md | 11 + .../alchemy/references/docker/_overview.md | 125 + .../references/docker/build-and-push.md | 141 + .../references/docker/local-services.md | 155 + .../skills/alchemy/references/docker/setup.md | 72 + .../skills/alchemy/references/docker/swarm.md | 259 + .../alchemy/references/environments/INDEX.md | 16 + .../references/environments/auth-providers.md | 270 + .../alchemy/references/environments/ci.md | 638 ++ .../environments/custom-auth-provider.md | 238 + .../references/environments/doppler.md | 104 + .../references/environments/infisical.md | 85 + .../environments/local-development.md | 181 + .../references/environments/profiles.md | 220 + .../environments/secret-providers.md | 139 + .../references/environments/secrets.md | 190 + .../alchemy/references/environments/stages.md | 150 + .../skills/alchemy/references/fly/INDEX.md | 61 + .../alchemy/references/fly/_overview.md | 124 + .../alchemy/references/fly/compute/apps.md | 131 + .../fly/compute/connecting-services.md | 506 ++ .../references/fly/compute/deployments.md | 421 ++ .../references/fly/compute/machines.md | 256 + .../alchemy/references/fly/compute/regions.md | 172 + .../references/fly/compute/services.md | 535 ++ .../alchemy/references/fly/compute/sprites.md | 189 + .../references/fly/data/drizzle-postgres.md | 154 + .../alchemy/references/fly/data/postgres.md | 161 + .../alchemy/references/fly/data/redis.md | 122 + .../alchemy/references/fly/data/secrets.md | 199 + .../alchemy/references/fly/data/tigris.md | 112 + .../alchemy/references/fly/data/volumes.md | 105 + .../alchemy/references/fly/frontend/astro.md | 215 + .../references/fly/frontend/foldkit.md | 193 + .../alchemy/references/fly/frontend/nextjs.md | 152 + .../alchemy/references/fly/frontend/nuxt.md | 181 + .../alchemy/references/fly/frontend/octane.md | 187 + .../references/fly/frontend/react-router.md | 266 + .../references/fly/frontend/solidstart.md | 234 + .../references/fly/frontend/static-site.md | 196 + .../references/fly/frontend/sveltekit.md | 180 + .../references/fly/frontend/tanstack-start.md | 263 + .../alchemy/references/fly/frontend/vinext.md | 136 + .../alchemy/references/fly/frontend/vite.md | 200 + .../alchemy/references/fly/frontend/vocs.md | 164 + .../alchemy/references/fly/frontend/waku.md | 176 + .../references/fly/frontend/websites.md | 169 + .../alchemy/references/fly/networking.md | 281 + .../skills/alchemy/references/fly/setup.md | 119 + .../alchemy/references/fly/tutorial/part-1.md | 315 + .../alchemy/references/fly/tutorial/part-2.md | 128 + .../alchemy/references/fly/tutorial/part-3.md | 176 + .../alchemy/references/fly/tutorial/part-4.md | 144 + .../skills/alchemy/references/gcp/INDEX.md | 17 + .../alchemy/references/gcp/_overview.md | 152 + .../alchemy/references/gcp/guides/bindings.md | 265 + .../references/gcp/guides/cloud-run-api.md | 572 ++ .../references/gcp/guides/event-pipeline.md | 304 + .../references/gcp/guides/memorystore.md | 91 + .../skills/alchemy/references/gcp/setup.md | 120 + .../alchemy/references/getting-started.md | 140 + .../skills/alchemy/references/git/INDEX.md | 46 + .../alchemy/references/git/_overview.md | 124 + .../references/git/blocks/_overview.md | 115 + .../alchemy/references/git/blocks/auth.md | 81 + .../references/git/blocks/blob-store.md | 84 + .../alchemy/references/git/blocks/engine.md | 191 + .../alchemy/references/git/blocks/hasher.md | 124 + .../alchemy/references/git/blocks/registry.md | 69 + .../references/git/blocks/repositories.md | 201 + .../alchemy/references/git/blocks/server.md | 188 + .../alchemy/references/git/clone-and-push.md | 128 + .../alchemy/references/git/getting-started.md | 186 + .../alchemy/references/git/github-api.md | 80 + .../alchemy/references/git/pull-requests.md | 102 + .../references/git/recipes/_overview.md | 76 + .../references/git/recipes/cloudflare-aws.md | 123 + .../references/git/recipes/cloudflare.md | 115 + .../alchemy/references/git/recipes/scaling.md | 123 + .../references/git/recipes/your-own-store.md | 95 + .../alchemy/references/git/repositories.md | 113 + .../alchemy/references/git/tutorial/part-1.md | 241 + .../alchemy/references/git/tutorial/part-2.md | 174 + .../alchemy/references/git/tutorial/part-3.md | 143 + .../alchemy/references/git/tutorial/part-4.md | 373 ++ .../alchemy/references/git/tutorial/part-5.md | 155 + .../alchemy/references/git/tutorial/part-6.md | 230 + .../skills/alchemy/references/github/INDEX.md | 11 + .../alchemy/references/github/_overview.md | 132 + .../references/github/actions-config.md | 259 + .../alchemy/references/github/events.md | 133 + .../alchemy/references/github/repository.md | 227 + .../skills/alchemy/references/github/setup.md | 113 + .../alchemy/references/hetzner/INDEX.md | 58 + .../alchemy/references/hetzner/_overview.md | 102 + .../references/hetzner/compute/servers.md | 193 + .../references/hetzner/compute/services.md | 204 + .../hetzner/data/drizzle-postgres.md | 171 + .../references/hetzner/data/volumes.md | 105 + .../references/hetzner/frontend/astro.md | 236 + .../references/hetzner/frontend/foldkit.md | 197 + .../references/hetzner/frontend/nextjs.md | 158 + .../references/hetzner/frontend/nuxt.md | 201 + .../references/hetzner/frontend/octane.md | 201 + .../hetzner/frontend/react-router.md | 273 + .../references/hetzner/frontend/solidstart.md | 240 + .../hetzner/frontend/static-site.md | 216 + .../references/hetzner/frontend/sveltekit.md | 187 + .../hetzner/frontend/tanstack-start.md | 269 + .../references/hetzner/frontend/vinext.md | 134 + .../references/hetzner/frontend/vite.md | 214 + .../references/hetzner/frontend/vocs.md | 188 + .../references/hetzner/frontend/waku.md | 183 + .../references/hetzner/frontend/websites.md | 181 + .../hetzner/networking/_overview.md | 179 + .../references/hetzner/networking/dns.md | 131 + .../alchemy/references/hetzner/setup.md | 113 + .../references/hetzner/tutorial/part-1.md | 290 + .../references/hetzner/tutorial/part-2.md | 296 + .../references/hetzner/tutorial/part-3.md | 196 + .../references/hetzner/tutorial/part-4.md | 238 + .../infrastructure-as-code/INDEX.md | 16 + .../infrastructure-as-code/action.md | 228 + .../infrastructure-as-code/custom-provider.md | 649 ++ .../infrastructure-as-code/local-provider.md | 244 + .../infrastructure-as-code/outputs.md | 351 + .../infrastructure-as-code/provider.md | 295 + .../infrastructure-as-code/references.md | 128 + .../infrastructure-as-code/renaming.md | 187 + .../resource-lifecycle.md | 257 + .../infrastructure-as-code/resource.md | 387 ++ .../infrastructure-as-code/stack.md | 237 + .../infrastructure-as-effects/INDEX.md | 15 + .../infrastructure-as-effects/binding.md | 303 + .../circular-bindings.md | 236 + .../custom-runtime.md | 375 ++ .../event-sources.md | 255 + .../infrastructure-as-effects/layers.md | 245 + .../infrastructure-as-effects/phases.md | 167 + .../infrastructure-as-effects/runtime.md | 398 ++ .../infrastructure-as-effects/sinks.md | 189 + .../infrastructure-as-effects/telemetry.md | 213 + .../alchemy/references/kubernetes/INDEX.md | 45 + .../references/kubernetes/_overview.md | 130 + .../kubernetes/clusters/cluster-adapters.md | 178 + .../kubernetes/clusters/connecting.md | 234 + .../references/kubernetes/clusters/eks.md | 38 + .../references/kubernetes/clusters/local.md | 241 + .../kubernetes/clusters/registries.md | 150 + .../kubernetes/objects/helm-charts.md | 147 + .../kubernetes/objects/manifests.md | 220 + .../alchemy/references/kubernetes/setup.md | 314 + .../references/kubernetes/tutorial/part-1.md | 242 + .../references/kubernetes/tutorial/part-2.md | 174 + .../references/kubernetes/tutorial/part-3.md | 409 ++ .../references/kubernetes/tutorial/part-4.md | 142 + .../references/kubernetes/tutorial/part-5.md | 220 + .../kubernetes/workloads/bindings.md | 140 + .../kubernetes/workloads/deployments.md | 292 + .../references/kubernetes/workloads/images.md | 201 + .../references/kubernetes/workloads/jobs.md | 257 + .../kubernetes/workloads/object-lifecycle.md | 165 + .../skills/alchemy/references/manifest.json | 5821 +++++++++++++++++ .../alchemy/references/migrating-from-v1.md | 294 + .../skills/alchemy/references/neon/INDEX.md | 57 + .../alchemy/references/neon/_overview.md | 197 + .../alchemy/references/neon/data/branching.md | 147 + .../references/neon/data/connections.md | 99 + .../references/neon/data/migrations.md | 118 + .../alchemy/references/neon/frontend/astro.md | 97 + .../references/neon/frontend/foldkit.md | 86 + .../references/neon/frontend/nextjs.md | 93 + .../alchemy/references/neon/frontend/nuxt.md | 86 + .../references/neon/frontend/octane.md | 97 + .../references/neon/frontend/react-router.md | 84 + .../references/neon/frontend/solidstart.md | 82 + .../references/neon/frontend/static-site.md | 101 + .../references/neon/frontend/sveltekit.md | 85 + .../neon/frontend/tanstack-start.md | 82 + .../alchemy/references/neon/frontend/vite.md | 155 + .../alchemy/references/neon/frontend/vocs.md | 93 + .../alchemy/references/neon/frontend/waku.md | 84 + .../alchemy/references/neon/governance.md | 232 + .../references/neon/guides/ai-gateway.md | 107 + .../references/neon/guides/custom-domains.md | 151 + .../alchemy/references/neon/guides/drizzle.md | 86 + .../neon/guides/preview-branches.md | 165 + .../neon/guides/private-networking.md | 151 + .../references/neon/guides/production-auth.md | 197 + .../references/neon/guides/state-recovery.md | 151 + .../skills/alchemy/references/neon/setup.md | 147 + .../references/neon/tutorial/_overview.md | 66 + .../references/neon/tutorial/backend.md | 139 + .../references/neon/tutorial/frontend.md | 155 + .../references/neon/tutorial/functions.md | 194 + .../references/neon/tutorial/previews.md | 140 + .../alchemy/references/planetscale/INDEX.md | 25 + .../references/planetscale/_overview.md | 93 + .../references/planetscale/data/backups.md | 79 + .../planetscale/data/credentials.md | 138 + .../references/planetscale/data/migrations.md | 169 + .../references/planetscale/data/mysql.md | 191 + .../references/planetscale/data/postgres.md | 202 + .../references/planetscale/guides/drizzle.md | 135 + .../planetscale/guides/preview-branches.md | 188 + .../alchemy/references/planetscale/setup.md | 58 + .../skills/alchemy/references/prisma/INDEX.md | 60 + .../alchemy/references/prisma/_overview.md | 141 + .../alchemy/references/prisma/compute/apps.md | 162 + .../references/prisma/compute/deployments.md | 113 + .../references/prisma/data/branches.md | 99 + .../alchemy/references/prisma/data/buckets.md | 110 + .../references/prisma/data/connections.md | 143 + .../prisma/data/drizzle-postgres.md | 158 + .../references/prisma/data/postgres.md | 101 + .../references/prisma/frontend/astro.md | 163 + .../references/prisma/frontend/foldkit.md | 160 + .../references/prisma/frontend/nextjs.md | 123 + .../references/prisma/frontend/nuxt.md | 138 + .../references/prisma/frontend/octane.md | 149 + .../prisma/frontend/react-router.md | 174 + .../references/prisma/frontend/solidstart.md | 162 + .../references/prisma/frontend/static-site.md | 178 + .../references/prisma/frontend/sveltekit.md | 140 + .../prisma/frontend/tanstack-start.md | 175 + .../references/prisma/frontend/vinext.md | 143 + .../references/prisma/frontend/vite.md | 154 + .../references/prisma/frontend/vocs.md | 136 + .../references/prisma/frontend/waku.md | 144 + .../references/prisma/frontend/websites.md | 208 + .../prisma/guides/cloudflare-workers.md | 99 + .../skills/alchemy/references/prisma/setup.md | 58 + .../references/prisma/tutorial/part-1.md | 143 + .../references/prisma/tutorial/part-2.md | 145 + .../references/prisma/tutorial/part-3.md | 153 + .../references/prisma/tutorial/part-4.md | 193 + .../references/project-structure/INDEX.md | 10 + .../project-structure/file-layout.md | 249 + .../project-structure/monorepo-multi-stack.md | 163 + .../monorepo-single-stack.md | 210 + .../references/project-structure/monorepo.md | 66 + .../alchemy/references/railway/INDEX.md | 62 + .../alchemy/references/railway/_overview.md | 138 + .../railway/compute/environments.md | 97 + .../references/railway/compute/functions.md | 190 + .../references/railway/compute/projects.md | 112 + .../references/railway/compute/regions.md | 106 + .../references/railway/compute/services.md | 366 ++ .../references/railway/data/buckets.md | 112 + .../references/railway/data/drizzle-mysql.md | 151 + .../railway/data/drizzle-postgres.md | 155 + .../alchemy/references/railway/data/mongo.md | 90 + .../alchemy/references/railway/data/mysql.md | 92 + .../references/railway/data/postgres.md | 151 + .../alchemy/references/railway/data/redis.md | 115 + .../references/railway/data/variables.md | 186 + .../references/railway/data/volumes.md | 146 + .../references/railway/frontend/astro.md | 218 + .../references/railway/frontend/foldkit.md | 196 + .../references/railway/frontend/nextjs.md | 158 + .../references/railway/frontend/nuxt.md | 183 + .../references/railway/frontend/octane.md | 188 + .../railway/frontend/react-router.md | 268 + .../references/railway/frontend/solidstart.md | 235 + .../railway/frontend/static-site.md | 204 + .../references/railway/frontend/sveltekit.md | 183 + .../railway/frontend/tanstack-start.md | 265 + .../references/railway/frontend/vinext.md | 137 + .../references/railway/frontend/vite.md | 207 + .../references/railway/frontend/vocs.md | 174 + .../references/railway/frontend/waku.md | 175 + .../references/railway/frontend/websites.md | 180 + .../alchemy/references/railway/networking.md | 164 + .../alchemy/references/railway/setup.md | 125 + .../references/railway/tutorial/part-1.md | 249 + .../references/railway/tutorial/part-2.md | 288 + .../references/railway/tutorial/part-3.md | 212 + .../references/railway/tutorial/part-4.md | 167 + .../skills/alchemy/references/sql/INDEX.md | 35 + .../alchemy/references/sql/_overview.md | 74 + .../alchemy/references/sql/databases.md | 51 + .../alchemy/references/sql/drizzle/d1.md | 159 + .../references/sql/drizzle/migrations.md | 237 + .../alchemy/references/sql/drizzle/mysql.md | 140 + .../references/sql/drizzle/postgres.md | 132 + .../alchemy/references/sql/effect-sql/d1.md | 134 + .../references/sql/effect-sql/lifecycle.md | 81 + .../references/sql/effect-sql/migrations.md | 240 + .../references/sql/effect-sql/mysql.md | 136 + .../references/sql/effect-sql/postgres.md | 110 + .../references/sql/prisma/contracts.md | 343 + .../references/sql/prisma/migrations.md | 154 + .../alchemy/references/sql/prisma/postgres.md | 203 + .../alchemy/references/state-store/INDEX.md | 8 + .../references/state-store/_overview.md | 196 + .../state-store/custom-state-store.md | 442 ++ .../skills/alchemy/references/stripe/INDEX.md | 16 + .../alchemy/references/stripe/_overview.md | 116 + .../references/stripe/guides/connect.md | 493 ++ .../references/stripe/guides/subscriptions.md | 555 ++ .../references/stripe/guides/webhooks.md | 135 + .../skills/alchemy/references/stripe/setup.md | 103 + .../alchemy/references/testing/INDEX.md | 11 + .../alchemy/references/testing/_overview.md | 75 + .../references/testing/observability.md | 219 + .../references/testing/test-harness.md | 315 + .../references/testing/testing-a-stack.md | 219 + .../references/testing/testing-providers.md | 77 + .../alchemy/references/what-is-alchemy.md | 317 + .../scripts/build-skill-effects.test.ts | 37 + .agents/skills/alchemy/scripts/build-skill.ts | 551 ++ .../alchemy/scripts/effect-version.test.ts | 68 + .../skills/alchemy/scripts/lib/mdx.test.ts | 167 + .agents/skills/alchemy/scripts/lib/mdx.ts | 323 + .agents/skills/cloudflare/SKILL.md | 21 +- .../skills/cloudflare/references/c3/README.md | 112 - .../skills/cloudflare/references/c3/api.md | 70 - .../cloudflare/references/c3/configuration.md | 81 - .../cloudflare/references/c3/gotchas.md | 92 - .../cloudflare/references/c3/patterns.md | 80 - .agents/skills/oxfmt/SKILL.md | 54 + .agents/skills/oxfmt/agents/openai.yaml | 4 + .../references/languages-sorting-ignores.md | 235 + .../oxfmt/references/prettier-migration-ci.md | 231 + .../oxfmt/references/setup-cli-config.md | 170 + .agents/skills/oxfmt/references/source-map.md | 97 + .../skills/oxfmt/references/usage-guide.md | 414 ++ .agents/skills/oxlint/SKILL.md | 230 + .agents/skills/tegami/SKILL.md | 39 + skills-lock.json | 18 +- 538 files changed, 108860 insertions(+), 1961 deletions(-) create mode 100644 .agents/skills/alchemy/package.json delete mode 100644 .agents/skills/alchemy/reference/examples.md delete mode 100644 .agents/skills/alchemy/reference/provider-implementation.md delete mode 100644 .agents/skills/alchemy/reference/upstream-concepts.md create mode 100644 .agents/skills/alchemy/references/acme/INDEX.md create mode 100644 .agents/skills/alchemy/references/acme/_overview.md create mode 100644 .agents/skills/alchemy/references/acme/certificate-authorities.md create mode 100644 .agents/skills/alchemy/references/acme/dns-validation.md create mode 100644 .agents/skills/alchemy/references/acme/getting-started.md create mode 100644 .agents/skills/alchemy/references/acme/renewal.md create mode 100644 .agents/skills/alchemy/references/acme/runtime.md create mode 100644 .agents/skills/alchemy/references/acme/troubleshooting.md create mode 100644 .agents/skills/alchemy/references/acme/using-certificates.md create mode 100644 .agents/skills/alchemy/references/apis/INDEX.md create mode 100644 .agents/skills/alchemy/references/apis/_overview.md create mode 100644 .agents/skills/alchemy/references/apis/effect-http.md create mode 100644 .agents/skills/alchemy/references/apis/effect-rpc.md create mode 100644 .agents/skills/alchemy/references/apis/schemaless.md create mode 100644 .agents/skills/alchemy/references/aws/INDEX.md create mode 100644 .agents/skills/alchemy/references/aws/_overview.md create mode 100644 .agents/skills/alchemy/references/aws/ai/bedrock.md create mode 100644 .agents/skills/alchemy/references/aws/apis/api-gateway.md create mode 100644 .agents/skills/alchemy/references/aws/apis/effect-http-api.md create mode 100644 .agents/skills/alchemy/references/aws/apis/effect-rpc.md create mode 100644 .agents/skills/alchemy/references/aws/apis/schemaless-rpc.md create mode 100644 .agents/skills/alchemy/references/aws/compute/choosing-a-runtime.md create mode 100644 .agents/skills/alchemy/references/aws/compute/ec2.md create mode 100644 .agents/skills/alchemy/references/aws/compute/ecs.md create mode 100644 .agents/skills/alchemy/references/aws/compute/eks.md create mode 100644 .agents/skills/alchemy/references/aws/compute/hyperpod.md create mode 100644 .agents/skills/alchemy/references/aws/compute/lambda.md create mode 100644 .agents/skills/alchemy/references/aws/compute/microvms.md create mode 100644 .agents/skills/alchemy/references/aws/data/drizzle-aurora.md create mode 100644 .agents/skills/alchemy/references/aws/data/drizzle-dsql.md create mode 100644 .agents/skills/alchemy/references/aws/data/dynamodb.md create mode 100644 .agents/skills/alchemy/references/aws/data/rds.md create mode 100644 .agents/skills/alchemy/references/aws/data/s3.md create mode 100644 .agents/skills/alchemy/references/aws/email/receiving.md create mode 100644 .agents/skills/alchemy/references/aws/email/sending.md create mode 100644 .agents/skills/alchemy/references/aws/frontend/astro.md create mode 100644 .agents/skills/alchemy/references/aws/frontend/foldkit.md create mode 100644 .agents/skills/alchemy/references/aws/frontend/full-stack-tanstack-rpc-drizzle.md create mode 100644 .agents/skills/alchemy/references/aws/frontend/nextjs.md create mode 100644 .agents/skills/alchemy/references/aws/frontend/nuxt.md create mode 100644 .agents/skills/alchemy/references/aws/frontend/octane.md create mode 100644 .agents/skills/alchemy/references/aws/frontend/react-router.md create mode 100644 .agents/skills/alchemy/references/aws/frontend/solidstart.md create mode 100644 .agents/skills/alchemy/references/aws/frontend/static-site.md create mode 100644 .agents/skills/alchemy/references/aws/frontend/sveltekit.md create mode 100644 .agents/skills/alchemy/references/aws/frontend/tanstack-start.md create mode 100644 .agents/skills/alchemy/references/aws/frontend/vinext.md create mode 100644 .agents/skills/alchemy/references/aws/frontend/vite-spa.md create mode 100644 .agents/skills/alchemy/references/aws/frontend/vite.md create mode 100644 .agents/skills/alchemy/references/aws/frontend/vue.md create mode 100644 .agents/skills/alchemy/references/aws/frontend/waku.md create mode 100644 .agents/skills/alchemy/references/aws/frontend/websites.md create mode 100644 .agents/skills/alchemy/references/aws/local-development.md create mode 100644 .agents/skills/alchemy/references/aws/messaging/dynamodb-streams.md create mode 100644 .agents/skills/alchemy/references/aws/messaging/eventbridge.md create mode 100644 .agents/skills/alchemy/references/aws/messaging/kinesis.md create mode 100644 .agents/skills/alchemy/references/aws/messaging/s3-events.md create mode 100644 .agents/skills/alchemy/references/aws/messaging/sns.md create mode 100644 .agents/skills/alchemy/references/aws/messaging/sqs.md create mode 100644 .agents/skills/alchemy/references/aws/networking/_overview.md create mode 100644 .agents/skills/alchemy/references/aws/networking/custom-domains.md create mode 100644 .agents/skills/alchemy/references/aws/observability/cloudwatch.md create mode 100644 .agents/skills/alchemy/references/aws/security/secrets-env.md create mode 100644 .agents/skills/alchemy/references/aws/setup.md create mode 100644 .agents/skills/alchemy/references/aws/tutorial/part-1.md create mode 100644 .agents/skills/alchemy/references/aws/tutorial/part-2.md create mode 100644 .agents/skills/alchemy/references/aws/tutorial/part-3.md create mode 100644 .agents/skills/alchemy/references/aws/tutorial/part-4.md create mode 100644 .agents/skills/alchemy/references/aws/tutorial/part-5.md create mode 100644 .agents/skills/alchemy/references/axiom/INDEX.md create mode 100644 .agents/skills/alchemy/references/axiom/_overview.md create mode 100644 .agents/skills/alchemy/references/axiom/data/ingest.md create mode 100644 .agents/skills/alchemy/references/axiom/guides/alerting.md create mode 100644 .agents/skills/alchemy/references/axiom/guides/annotations.md create mode 100644 .agents/skills/alchemy/references/axiom/guides/dashboards.md create mode 100644 .agents/skills/alchemy/references/axiom/setup.md create mode 100644 .agents/skills/alchemy/references/better-auth/INDEX.md create mode 100644 .agents/skills/alchemy/references/better-auth/_overview.md create mode 100644 .agents/skills/alchemy/references/better-auth/databases/_overview.md create mode 100644 .agents/skills/alchemy/references/better-auth/databases/aurora-data-api.md create mode 100644 .agents/skills/alchemy/references/better-auth/databases/cloudflare-d1.md create mode 100644 .agents/skills/alchemy/references/better-auth/databases/cloudflare-hyperdrive.md create mode 100644 .agents/skills/alchemy/references/better-auth/databases/drizzle.md create mode 100644 .agents/skills/alchemy/references/better-auth/databases/memory.md create mode 100644 .agents/skills/alchemy/references/better-auth/databases/mysql.md create mode 100644 .agents/skills/alchemy/references/better-auth/databases/neon.md create mode 100644 .agents/skills/alchemy/references/better-auth/databases/postgres.md create mode 100644 .agents/skills/alchemy/references/better-auth/databases/sqlite.md create mode 100644 .agents/skills/alchemy/references/better-auth/guides/configuration.md create mode 100644 .agents/skills/alchemy/references/better-auth/guides/http-api-middleware.md create mode 100644 .agents/skills/alchemy/references/better-auth/guides/migrations.md create mode 100644 .agents/skills/alchemy/references/better-auth/guides/secondary-storage.md create mode 100644 .agents/skills/alchemy/references/better-auth/sign-in-providers/_overview.md create mode 100644 .agents/skills/alchemy/references/better-auth/sign-in-providers/custom-oauth.md create mode 100644 .agents/skills/alchemy/references/better-auth/sign-in-providers/email-password.md create mode 100644 .agents/skills/alchemy/references/better-auth/sign-in-providers/github.md create mode 100644 .agents/skills/alchemy/references/better-auth/sign-in-providers/google.md create mode 100644 .agents/skills/alchemy/references/better-auth/sign-in-providers/microsoft.md create mode 100644 .agents/skills/alchemy/references/better-auth/tutorial/part-1.md create mode 100644 .agents/skills/alchemy/references/better-auth/tutorial/part-2.md create mode 100644 .agents/skills/alchemy/references/better-auth/tutorial/part-3.md create mode 100644 .agents/skills/alchemy/references/better-auth/tutorial/part-4.md create mode 100644 .agents/skills/alchemy/references/better-auth/tutorial/part-5.md create mode 100644 .agents/skills/alchemy/references/better-auth/tutorial/part-6.md create mode 100644 .agents/skills/alchemy/references/better-auth/upgrades/from-1-6-to-1-7.md create mode 100644 .agents/skills/alchemy/references/cli/INDEX.md create mode 100644 .agents/skills/alchemy/references/cli/_overview.md create mode 100644 .agents/skills/alchemy/references/cli/adopting-resources.md create mode 100644 .agents/skills/alchemy/references/cli/aws.md create mode 100644 .agents/skills/alchemy/references/cli/cloudflare.md create mode 100644 .agents/skills/alchemy/references/cli/deploy.md create mode 100644 .agents/skills/alchemy/references/cli/destroy.md create mode 100644 .agents/skills/alchemy/references/cli/dev.md create mode 100644 .agents/skills/alchemy/references/cli/drift.md create mode 100644 .agents/skills/alchemy/references/cli/inspecting-state.md create mode 100644 .agents/skills/alchemy/references/cli/logs.md create mode 100644 .agents/skills/alchemy/references/cli/nuke.md create mode 100644 .agents/skills/alchemy/references/cli/plan.md create mode 100644 .agents/skills/alchemy/references/cli/profile.md create mode 100644 .agents/skills/alchemy/references/cli/state.md create mode 100644 .agents/skills/alchemy/references/cloudflare/INDEX.md create mode 100644 .agents/skills/alchemy/references/cloudflare/_overview.md create mode 100644 .agents/skills/alchemy/references/cloudflare/ai/ai-gateway.md create mode 100644 .agents/skills/alchemy/references/cloudflare/ai/ai-search.md create mode 100644 .agents/skills/alchemy/references/cloudflare/ai/effect-ai.md create mode 100644 .agents/skills/alchemy/references/cloudflare/ai/release-agent.md create mode 100644 .agents/skills/alchemy/references/cloudflare/ai/vectorize.md create mode 100644 .agents/skills/alchemy/references/cloudflare/ai/workers-ai.md create mode 100644 .agents/skills/alchemy/references/cloudflare/apis/effect-http-api.md create mode 100644 .agents/skills/alchemy/references/cloudflare/apis/effect-rpc.md create mode 100644 .agents/skills/alchemy/references/cloudflare/apis/schemaless-rpc.md create mode 100644 .agents/skills/alchemy/references/cloudflare/compute/add-a-workflow.md create mode 100644 .agents/skills/alchemy/references/cloudflare/compute/browser-rendering.md create mode 100644 .agents/skills/alchemy/references/cloudflare/compute/cache.md create mode 100644 .agents/skills/alchemy/references/cloudflare/compute/containers.md create mode 100644 .agents/skills/alchemy/references/cloudflare/compute/cross-worker-durable-object.md create mode 100644 .agents/skills/alchemy/references/cloudflare/compute/durable-objects.md create mode 100644 .agents/skills/alchemy/references/cloudflare/compute/gradual-deployments.md create mode 100644 .agents/skills/alchemy/references/cloudflare/compute/hibernatable-websockets.md create mode 100644 .agents/skills/alchemy/references/cloudflare/compute/previews.md create mode 100644 .agents/skills/alchemy/references/cloudflare/compute/python-workers.md create mode 100644 .agents/skills/alchemy/references/cloudflare/compute/rate-limiting.md create mode 100644 .agents/skills/alchemy/references/cloudflare/compute/run-a-container.md create mode 100644 .agents/skills/alchemy/references/cloudflare/compute/worker-loader.md create mode 100644 .agents/skills/alchemy/references/cloudflare/compute/workers-for-platforms.md create mode 100644 .agents/skills/alchemy/references/cloudflare/compute/workers.md create mode 100644 .agents/skills/alchemy/references/cloudflare/compute/workflows.md create mode 100644 .agents/skills/alchemy/references/cloudflare/data/artifacts.md create mode 100644 .agents/skills/alchemy/references/cloudflare/data/branch-from-shared-database.md create mode 100644 .agents/skills/alchemy/references/cloudflare/data/d1-drizzle.md create mode 100644 .agents/skills/alchemy/references/cloudflare/data/d1.md create mode 100644 .agents/skills/alchemy/references/cloudflare/data/drizzle.md create mode 100644 .agents/skills/alchemy/references/cloudflare/data/hyperdrive.md create mode 100644 .agents/skills/alchemy/references/cloudflare/data/kv.md create mode 100644 .agents/skills/alchemy/references/cloudflare/data/prisma.md create mode 100644 .agents/skills/alchemy/references/cloudflare/data/r2-presigned-urls.md create mode 100644 .agents/skills/alchemy/references/cloudflare/data/r2.md create mode 100644 .agents/skills/alchemy/references/cloudflare/data/shared-database.md create mode 100644 .agents/skills/alchemy/references/cloudflare/email/_overview.md create mode 100644 .agents/skills/alchemy/references/cloudflare/email/email-worker.md create mode 100644 .agents/skills/alchemy/references/cloudflare/email/send-and-receive.md create mode 100644 .agents/skills/alchemy/references/cloudflare/frontend/astro.md create mode 100644 .agents/skills/alchemy/references/cloudflare/frontend/foldkit.md create mode 100644 .agents/skills/alchemy/references/cloudflare/frontend/frontends.md create mode 100644 .agents/skills/alchemy/references/cloudflare/frontend/full-stack-tanstack-rpc-drizzle.md create mode 100644 .agents/skills/alchemy/references/cloudflare/frontend/nextjs.md create mode 100644 .agents/skills/alchemy/references/cloudflare/frontend/nuxt.md create mode 100644 .agents/skills/alchemy/references/cloudflare/frontend/octane.md create mode 100644 .agents/skills/alchemy/references/cloudflare/frontend/react-router.md create mode 100644 .agents/skills/alchemy/references/cloudflare/frontend/solidstart.md create mode 100644 .agents/skills/alchemy/references/cloudflare/frontend/static-site.md create mode 100644 .agents/skills/alchemy/references/cloudflare/frontend/sveltekit.md create mode 100644 .agents/skills/alchemy/references/cloudflare/frontend/tanstack-start.md create mode 100644 .agents/skills/alchemy/references/cloudflare/frontend/vinext.md create mode 100644 .agents/skills/alchemy/references/cloudflare/frontend/vite-spa.md create mode 100644 .agents/skills/alchemy/references/cloudflare/frontend/vite.md create mode 100644 .agents/skills/alchemy/references/cloudflare/frontend/vue.md create mode 100644 .agents/skills/alchemy/references/cloudflare/frontend/waku.md create mode 100644 .agents/skills/alchemy/references/cloudflare/local-development.md create mode 100644 .agents/skills/alchemy/references/cloudflare/messaging/cron.md create mode 100644 .agents/skills/alchemy/references/cloudflare/messaging/github-events.md create mode 100644 .agents/skills/alchemy/references/cloudflare/messaging/queues.md create mode 100644 .agents/skills/alchemy/references/cloudflare/networking/custom-domains.md create mode 100644 .agents/skills/alchemy/references/cloudflare/networking/domains.md create mode 100644 .agents/skills/alchemy/references/cloudflare/networking/tunnel.md create mode 100644 .agents/skills/alchemy/references/cloudflare/observability/analytics-engine.md create mode 100644 .agents/skills/alchemy/references/cloudflare/observability/axiom-observability.md create mode 100644 .agents/skills/alchemy/references/cloudflare/observability/workers-tracing.md create mode 100644 .agents/skills/alchemy/references/cloudflare/security/access.md create mode 100644 .agents/skills/alchemy/references/cloudflare/security/secrets-env.md create mode 100644 .agents/skills/alchemy/references/cloudflare/security/secrets-store.md create mode 100644 .agents/skills/alchemy/references/cloudflare/security/turnstile.md create mode 100644 .agents/skills/alchemy/references/cloudflare/setup.md create mode 100644 .agents/skills/alchemy/references/cloudflare/tutorial/part-1.md create mode 100644 .agents/skills/alchemy/references/cloudflare/tutorial/part-2.md create mode 100644 .agents/skills/alchemy/references/cloudflare/tutorial/part-3.md create mode 100644 .agents/skills/alchemy/references/cloudflare/tutorial/part-4.md create mode 100644 .agents/skills/alchemy/references/cloudflare/tutorial/part-5.md create mode 100644 .agents/skills/alchemy/references/cloudflare/tutorial/part-6.md create mode 100644 .agents/skills/alchemy/references/command/INDEX.md create mode 100644 .agents/skills/alchemy/references/command/_overview.md create mode 100644 .agents/skills/alchemy/references/command/dev-servers.md create mode 100644 .agents/skills/alchemy/references/command/memoization.md create mode 100644 .agents/skills/alchemy/references/docker/INDEX.md create mode 100644 .agents/skills/alchemy/references/docker/_overview.md create mode 100644 .agents/skills/alchemy/references/docker/build-and-push.md create mode 100644 .agents/skills/alchemy/references/docker/local-services.md create mode 100644 .agents/skills/alchemy/references/docker/setup.md create mode 100644 .agents/skills/alchemy/references/docker/swarm.md create mode 100644 .agents/skills/alchemy/references/environments/INDEX.md create mode 100644 .agents/skills/alchemy/references/environments/auth-providers.md create mode 100644 .agents/skills/alchemy/references/environments/ci.md create mode 100644 .agents/skills/alchemy/references/environments/custom-auth-provider.md create mode 100644 .agents/skills/alchemy/references/environments/doppler.md create mode 100644 .agents/skills/alchemy/references/environments/infisical.md create mode 100644 .agents/skills/alchemy/references/environments/local-development.md create mode 100644 .agents/skills/alchemy/references/environments/profiles.md create mode 100644 .agents/skills/alchemy/references/environments/secret-providers.md create mode 100644 .agents/skills/alchemy/references/environments/secrets.md create mode 100644 .agents/skills/alchemy/references/environments/stages.md create mode 100644 .agents/skills/alchemy/references/fly/INDEX.md create mode 100644 .agents/skills/alchemy/references/fly/_overview.md create mode 100644 .agents/skills/alchemy/references/fly/compute/apps.md create mode 100644 .agents/skills/alchemy/references/fly/compute/connecting-services.md create mode 100644 .agents/skills/alchemy/references/fly/compute/deployments.md create mode 100644 .agents/skills/alchemy/references/fly/compute/machines.md create mode 100644 .agents/skills/alchemy/references/fly/compute/regions.md create mode 100644 .agents/skills/alchemy/references/fly/compute/services.md create mode 100644 .agents/skills/alchemy/references/fly/compute/sprites.md create mode 100644 .agents/skills/alchemy/references/fly/data/drizzle-postgres.md create mode 100644 .agents/skills/alchemy/references/fly/data/postgres.md create mode 100644 .agents/skills/alchemy/references/fly/data/redis.md create mode 100644 .agents/skills/alchemy/references/fly/data/secrets.md create mode 100644 .agents/skills/alchemy/references/fly/data/tigris.md create mode 100644 .agents/skills/alchemy/references/fly/data/volumes.md create mode 100644 .agents/skills/alchemy/references/fly/frontend/astro.md create mode 100644 .agents/skills/alchemy/references/fly/frontend/foldkit.md create mode 100644 .agents/skills/alchemy/references/fly/frontend/nextjs.md create mode 100644 .agents/skills/alchemy/references/fly/frontend/nuxt.md create mode 100644 .agents/skills/alchemy/references/fly/frontend/octane.md create mode 100644 .agents/skills/alchemy/references/fly/frontend/react-router.md create mode 100644 .agents/skills/alchemy/references/fly/frontend/solidstart.md create mode 100644 .agents/skills/alchemy/references/fly/frontend/static-site.md create mode 100644 .agents/skills/alchemy/references/fly/frontend/sveltekit.md create mode 100644 .agents/skills/alchemy/references/fly/frontend/tanstack-start.md create mode 100644 .agents/skills/alchemy/references/fly/frontend/vinext.md create mode 100644 .agents/skills/alchemy/references/fly/frontend/vite.md create mode 100644 .agents/skills/alchemy/references/fly/frontend/vocs.md create mode 100644 .agents/skills/alchemy/references/fly/frontend/waku.md create mode 100644 .agents/skills/alchemy/references/fly/frontend/websites.md create mode 100644 .agents/skills/alchemy/references/fly/networking.md create mode 100644 .agents/skills/alchemy/references/fly/setup.md create mode 100644 .agents/skills/alchemy/references/fly/tutorial/part-1.md create mode 100644 .agents/skills/alchemy/references/fly/tutorial/part-2.md create mode 100644 .agents/skills/alchemy/references/fly/tutorial/part-3.md create mode 100644 .agents/skills/alchemy/references/fly/tutorial/part-4.md create mode 100644 .agents/skills/alchemy/references/gcp/INDEX.md create mode 100644 .agents/skills/alchemy/references/gcp/_overview.md create mode 100644 .agents/skills/alchemy/references/gcp/guides/bindings.md create mode 100644 .agents/skills/alchemy/references/gcp/guides/cloud-run-api.md create mode 100644 .agents/skills/alchemy/references/gcp/guides/event-pipeline.md create mode 100644 .agents/skills/alchemy/references/gcp/guides/memorystore.md create mode 100644 .agents/skills/alchemy/references/gcp/setup.md create mode 100644 .agents/skills/alchemy/references/getting-started.md create mode 100644 .agents/skills/alchemy/references/git/INDEX.md create mode 100644 .agents/skills/alchemy/references/git/_overview.md create mode 100644 .agents/skills/alchemy/references/git/blocks/_overview.md create mode 100644 .agents/skills/alchemy/references/git/blocks/auth.md create mode 100644 .agents/skills/alchemy/references/git/blocks/blob-store.md create mode 100644 .agents/skills/alchemy/references/git/blocks/engine.md create mode 100644 .agents/skills/alchemy/references/git/blocks/hasher.md create mode 100644 .agents/skills/alchemy/references/git/blocks/registry.md create mode 100644 .agents/skills/alchemy/references/git/blocks/repositories.md create mode 100644 .agents/skills/alchemy/references/git/blocks/server.md create mode 100644 .agents/skills/alchemy/references/git/clone-and-push.md create mode 100644 .agents/skills/alchemy/references/git/getting-started.md create mode 100644 .agents/skills/alchemy/references/git/github-api.md create mode 100644 .agents/skills/alchemy/references/git/pull-requests.md create mode 100644 .agents/skills/alchemy/references/git/recipes/_overview.md create mode 100644 .agents/skills/alchemy/references/git/recipes/cloudflare-aws.md create mode 100644 .agents/skills/alchemy/references/git/recipes/cloudflare.md create mode 100644 .agents/skills/alchemy/references/git/recipes/scaling.md create mode 100644 .agents/skills/alchemy/references/git/recipes/your-own-store.md create mode 100644 .agents/skills/alchemy/references/git/repositories.md create mode 100644 .agents/skills/alchemy/references/git/tutorial/part-1.md create mode 100644 .agents/skills/alchemy/references/git/tutorial/part-2.md create mode 100644 .agents/skills/alchemy/references/git/tutorial/part-3.md create mode 100644 .agents/skills/alchemy/references/git/tutorial/part-4.md create mode 100644 .agents/skills/alchemy/references/git/tutorial/part-5.md create mode 100644 .agents/skills/alchemy/references/git/tutorial/part-6.md create mode 100644 .agents/skills/alchemy/references/github/INDEX.md create mode 100644 .agents/skills/alchemy/references/github/_overview.md create mode 100644 .agents/skills/alchemy/references/github/actions-config.md create mode 100644 .agents/skills/alchemy/references/github/events.md create mode 100644 .agents/skills/alchemy/references/github/repository.md create mode 100644 .agents/skills/alchemy/references/github/setup.md create mode 100644 .agents/skills/alchemy/references/hetzner/INDEX.md create mode 100644 .agents/skills/alchemy/references/hetzner/_overview.md create mode 100644 .agents/skills/alchemy/references/hetzner/compute/servers.md create mode 100644 .agents/skills/alchemy/references/hetzner/compute/services.md create mode 100644 .agents/skills/alchemy/references/hetzner/data/drizzle-postgres.md create mode 100644 .agents/skills/alchemy/references/hetzner/data/volumes.md create mode 100644 .agents/skills/alchemy/references/hetzner/frontend/astro.md create mode 100644 .agents/skills/alchemy/references/hetzner/frontend/foldkit.md create mode 100644 .agents/skills/alchemy/references/hetzner/frontend/nextjs.md create mode 100644 .agents/skills/alchemy/references/hetzner/frontend/nuxt.md create mode 100644 .agents/skills/alchemy/references/hetzner/frontend/octane.md create mode 100644 .agents/skills/alchemy/references/hetzner/frontend/react-router.md create mode 100644 .agents/skills/alchemy/references/hetzner/frontend/solidstart.md create mode 100644 .agents/skills/alchemy/references/hetzner/frontend/static-site.md create mode 100644 .agents/skills/alchemy/references/hetzner/frontend/sveltekit.md create mode 100644 .agents/skills/alchemy/references/hetzner/frontend/tanstack-start.md create mode 100644 .agents/skills/alchemy/references/hetzner/frontend/vinext.md create mode 100644 .agents/skills/alchemy/references/hetzner/frontend/vite.md create mode 100644 .agents/skills/alchemy/references/hetzner/frontend/vocs.md create mode 100644 .agents/skills/alchemy/references/hetzner/frontend/waku.md create mode 100644 .agents/skills/alchemy/references/hetzner/frontend/websites.md create mode 100644 .agents/skills/alchemy/references/hetzner/networking/_overview.md create mode 100644 .agents/skills/alchemy/references/hetzner/networking/dns.md create mode 100644 .agents/skills/alchemy/references/hetzner/setup.md create mode 100644 .agents/skills/alchemy/references/hetzner/tutorial/part-1.md create mode 100644 .agents/skills/alchemy/references/hetzner/tutorial/part-2.md create mode 100644 .agents/skills/alchemy/references/hetzner/tutorial/part-3.md create mode 100644 .agents/skills/alchemy/references/hetzner/tutorial/part-4.md create mode 100644 .agents/skills/alchemy/references/infrastructure-as-code/INDEX.md create mode 100644 .agents/skills/alchemy/references/infrastructure-as-code/action.md create mode 100644 .agents/skills/alchemy/references/infrastructure-as-code/custom-provider.md create mode 100644 .agents/skills/alchemy/references/infrastructure-as-code/local-provider.md create mode 100644 .agents/skills/alchemy/references/infrastructure-as-code/outputs.md create mode 100644 .agents/skills/alchemy/references/infrastructure-as-code/provider.md create mode 100644 .agents/skills/alchemy/references/infrastructure-as-code/references.md create mode 100644 .agents/skills/alchemy/references/infrastructure-as-code/renaming.md create mode 100644 .agents/skills/alchemy/references/infrastructure-as-code/resource-lifecycle.md create mode 100644 .agents/skills/alchemy/references/infrastructure-as-code/resource.md create mode 100644 .agents/skills/alchemy/references/infrastructure-as-code/stack.md create mode 100644 .agents/skills/alchemy/references/infrastructure-as-effects/INDEX.md create mode 100644 .agents/skills/alchemy/references/infrastructure-as-effects/binding.md create mode 100644 .agents/skills/alchemy/references/infrastructure-as-effects/circular-bindings.md create mode 100644 .agents/skills/alchemy/references/infrastructure-as-effects/custom-runtime.md create mode 100644 .agents/skills/alchemy/references/infrastructure-as-effects/event-sources.md create mode 100644 .agents/skills/alchemy/references/infrastructure-as-effects/layers.md create mode 100644 .agents/skills/alchemy/references/infrastructure-as-effects/phases.md create mode 100644 .agents/skills/alchemy/references/infrastructure-as-effects/runtime.md create mode 100644 .agents/skills/alchemy/references/infrastructure-as-effects/sinks.md create mode 100644 .agents/skills/alchemy/references/infrastructure-as-effects/telemetry.md create mode 100644 .agents/skills/alchemy/references/kubernetes/INDEX.md create mode 100644 .agents/skills/alchemy/references/kubernetes/_overview.md create mode 100644 .agents/skills/alchemy/references/kubernetes/clusters/cluster-adapters.md create mode 100644 .agents/skills/alchemy/references/kubernetes/clusters/connecting.md create mode 100644 .agents/skills/alchemy/references/kubernetes/clusters/eks.md create mode 100644 .agents/skills/alchemy/references/kubernetes/clusters/local.md create mode 100644 .agents/skills/alchemy/references/kubernetes/clusters/registries.md create mode 100644 .agents/skills/alchemy/references/kubernetes/objects/helm-charts.md create mode 100644 .agents/skills/alchemy/references/kubernetes/objects/manifests.md create mode 100644 .agents/skills/alchemy/references/kubernetes/setup.md create mode 100644 .agents/skills/alchemy/references/kubernetes/tutorial/part-1.md create mode 100644 .agents/skills/alchemy/references/kubernetes/tutorial/part-2.md create mode 100644 .agents/skills/alchemy/references/kubernetes/tutorial/part-3.md create mode 100644 .agents/skills/alchemy/references/kubernetes/tutorial/part-4.md create mode 100644 .agents/skills/alchemy/references/kubernetes/tutorial/part-5.md create mode 100644 .agents/skills/alchemy/references/kubernetes/workloads/bindings.md create mode 100644 .agents/skills/alchemy/references/kubernetes/workloads/deployments.md create mode 100644 .agents/skills/alchemy/references/kubernetes/workloads/images.md create mode 100644 .agents/skills/alchemy/references/kubernetes/workloads/jobs.md create mode 100644 .agents/skills/alchemy/references/kubernetes/workloads/object-lifecycle.md create mode 100644 .agents/skills/alchemy/references/manifest.json create mode 100644 .agents/skills/alchemy/references/migrating-from-v1.md create mode 100644 .agents/skills/alchemy/references/neon/INDEX.md create mode 100644 .agents/skills/alchemy/references/neon/_overview.md create mode 100644 .agents/skills/alchemy/references/neon/data/branching.md create mode 100644 .agents/skills/alchemy/references/neon/data/connections.md create mode 100644 .agents/skills/alchemy/references/neon/data/migrations.md create mode 100644 .agents/skills/alchemy/references/neon/frontend/astro.md create mode 100644 .agents/skills/alchemy/references/neon/frontend/foldkit.md create mode 100644 .agents/skills/alchemy/references/neon/frontend/nextjs.md create mode 100644 .agents/skills/alchemy/references/neon/frontend/nuxt.md create mode 100644 .agents/skills/alchemy/references/neon/frontend/octane.md create mode 100644 .agents/skills/alchemy/references/neon/frontend/react-router.md create mode 100644 .agents/skills/alchemy/references/neon/frontend/solidstart.md create mode 100644 .agents/skills/alchemy/references/neon/frontend/static-site.md create mode 100644 .agents/skills/alchemy/references/neon/frontend/sveltekit.md create mode 100644 .agents/skills/alchemy/references/neon/frontend/tanstack-start.md create mode 100644 .agents/skills/alchemy/references/neon/frontend/vite.md create mode 100644 .agents/skills/alchemy/references/neon/frontend/vocs.md create mode 100644 .agents/skills/alchemy/references/neon/frontend/waku.md create mode 100644 .agents/skills/alchemy/references/neon/governance.md create mode 100644 .agents/skills/alchemy/references/neon/guides/ai-gateway.md create mode 100644 .agents/skills/alchemy/references/neon/guides/custom-domains.md create mode 100644 .agents/skills/alchemy/references/neon/guides/drizzle.md create mode 100644 .agents/skills/alchemy/references/neon/guides/preview-branches.md create mode 100644 .agents/skills/alchemy/references/neon/guides/private-networking.md create mode 100644 .agents/skills/alchemy/references/neon/guides/production-auth.md create mode 100644 .agents/skills/alchemy/references/neon/guides/state-recovery.md create mode 100644 .agents/skills/alchemy/references/neon/setup.md create mode 100644 .agents/skills/alchemy/references/neon/tutorial/_overview.md create mode 100644 .agents/skills/alchemy/references/neon/tutorial/backend.md create mode 100644 .agents/skills/alchemy/references/neon/tutorial/frontend.md create mode 100644 .agents/skills/alchemy/references/neon/tutorial/functions.md create mode 100644 .agents/skills/alchemy/references/neon/tutorial/previews.md create mode 100644 .agents/skills/alchemy/references/planetscale/INDEX.md create mode 100644 .agents/skills/alchemy/references/planetscale/_overview.md create mode 100644 .agents/skills/alchemy/references/planetscale/data/backups.md create mode 100644 .agents/skills/alchemy/references/planetscale/data/credentials.md create mode 100644 .agents/skills/alchemy/references/planetscale/data/migrations.md create mode 100644 .agents/skills/alchemy/references/planetscale/data/mysql.md create mode 100644 .agents/skills/alchemy/references/planetscale/data/postgres.md create mode 100644 .agents/skills/alchemy/references/planetscale/guides/drizzle.md create mode 100644 .agents/skills/alchemy/references/planetscale/guides/preview-branches.md create mode 100644 .agents/skills/alchemy/references/planetscale/setup.md create mode 100644 .agents/skills/alchemy/references/prisma/INDEX.md create mode 100644 .agents/skills/alchemy/references/prisma/_overview.md create mode 100644 .agents/skills/alchemy/references/prisma/compute/apps.md create mode 100644 .agents/skills/alchemy/references/prisma/compute/deployments.md create mode 100644 .agents/skills/alchemy/references/prisma/data/branches.md create mode 100644 .agents/skills/alchemy/references/prisma/data/buckets.md create mode 100644 .agents/skills/alchemy/references/prisma/data/connections.md create mode 100644 .agents/skills/alchemy/references/prisma/data/drizzle-postgres.md create mode 100644 .agents/skills/alchemy/references/prisma/data/postgres.md create mode 100644 .agents/skills/alchemy/references/prisma/frontend/astro.md create mode 100644 .agents/skills/alchemy/references/prisma/frontend/foldkit.md create mode 100644 .agents/skills/alchemy/references/prisma/frontend/nextjs.md create mode 100644 .agents/skills/alchemy/references/prisma/frontend/nuxt.md create mode 100644 .agents/skills/alchemy/references/prisma/frontend/octane.md create mode 100644 .agents/skills/alchemy/references/prisma/frontend/react-router.md create mode 100644 .agents/skills/alchemy/references/prisma/frontend/solidstart.md create mode 100644 .agents/skills/alchemy/references/prisma/frontend/static-site.md create mode 100644 .agents/skills/alchemy/references/prisma/frontend/sveltekit.md create mode 100644 .agents/skills/alchemy/references/prisma/frontend/tanstack-start.md create mode 100644 .agents/skills/alchemy/references/prisma/frontend/vinext.md create mode 100644 .agents/skills/alchemy/references/prisma/frontend/vite.md create mode 100644 .agents/skills/alchemy/references/prisma/frontend/vocs.md create mode 100644 .agents/skills/alchemy/references/prisma/frontend/waku.md create mode 100644 .agents/skills/alchemy/references/prisma/frontend/websites.md create mode 100644 .agents/skills/alchemy/references/prisma/guides/cloudflare-workers.md create mode 100644 .agents/skills/alchemy/references/prisma/setup.md create mode 100644 .agents/skills/alchemy/references/prisma/tutorial/part-1.md create mode 100644 .agents/skills/alchemy/references/prisma/tutorial/part-2.md create mode 100644 .agents/skills/alchemy/references/prisma/tutorial/part-3.md create mode 100644 .agents/skills/alchemy/references/prisma/tutorial/part-4.md create mode 100644 .agents/skills/alchemy/references/project-structure/INDEX.md create mode 100644 .agents/skills/alchemy/references/project-structure/file-layout.md create mode 100644 .agents/skills/alchemy/references/project-structure/monorepo-multi-stack.md create mode 100644 .agents/skills/alchemy/references/project-structure/monorepo-single-stack.md create mode 100644 .agents/skills/alchemy/references/project-structure/monorepo.md create mode 100644 .agents/skills/alchemy/references/railway/INDEX.md create mode 100644 .agents/skills/alchemy/references/railway/_overview.md create mode 100644 .agents/skills/alchemy/references/railway/compute/environments.md create mode 100644 .agents/skills/alchemy/references/railway/compute/functions.md create mode 100644 .agents/skills/alchemy/references/railway/compute/projects.md create mode 100644 .agents/skills/alchemy/references/railway/compute/regions.md create mode 100644 .agents/skills/alchemy/references/railway/compute/services.md create mode 100644 .agents/skills/alchemy/references/railway/data/buckets.md create mode 100644 .agents/skills/alchemy/references/railway/data/drizzle-mysql.md create mode 100644 .agents/skills/alchemy/references/railway/data/drizzle-postgres.md create mode 100644 .agents/skills/alchemy/references/railway/data/mongo.md create mode 100644 .agents/skills/alchemy/references/railway/data/mysql.md create mode 100644 .agents/skills/alchemy/references/railway/data/postgres.md create mode 100644 .agents/skills/alchemy/references/railway/data/redis.md create mode 100644 .agents/skills/alchemy/references/railway/data/variables.md create mode 100644 .agents/skills/alchemy/references/railway/data/volumes.md create mode 100644 .agents/skills/alchemy/references/railway/frontend/astro.md create mode 100644 .agents/skills/alchemy/references/railway/frontend/foldkit.md create mode 100644 .agents/skills/alchemy/references/railway/frontend/nextjs.md create mode 100644 .agents/skills/alchemy/references/railway/frontend/nuxt.md create mode 100644 .agents/skills/alchemy/references/railway/frontend/octane.md create mode 100644 .agents/skills/alchemy/references/railway/frontend/react-router.md create mode 100644 .agents/skills/alchemy/references/railway/frontend/solidstart.md create mode 100644 .agents/skills/alchemy/references/railway/frontend/static-site.md create mode 100644 .agents/skills/alchemy/references/railway/frontend/sveltekit.md create mode 100644 .agents/skills/alchemy/references/railway/frontend/tanstack-start.md create mode 100644 .agents/skills/alchemy/references/railway/frontend/vinext.md create mode 100644 .agents/skills/alchemy/references/railway/frontend/vite.md create mode 100644 .agents/skills/alchemy/references/railway/frontend/vocs.md create mode 100644 .agents/skills/alchemy/references/railway/frontend/waku.md create mode 100644 .agents/skills/alchemy/references/railway/frontend/websites.md create mode 100644 .agents/skills/alchemy/references/railway/networking.md create mode 100644 .agents/skills/alchemy/references/railway/setup.md create mode 100644 .agents/skills/alchemy/references/railway/tutorial/part-1.md create mode 100644 .agents/skills/alchemy/references/railway/tutorial/part-2.md create mode 100644 .agents/skills/alchemy/references/railway/tutorial/part-3.md create mode 100644 .agents/skills/alchemy/references/railway/tutorial/part-4.md create mode 100644 .agents/skills/alchemy/references/sql/INDEX.md create mode 100644 .agents/skills/alchemy/references/sql/_overview.md create mode 100644 .agents/skills/alchemy/references/sql/databases.md create mode 100644 .agents/skills/alchemy/references/sql/drizzle/d1.md create mode 100644 .agents/skills/alchemy/references/sql/drizzle/migrations.md create mode 100644 .agents/skills/alchemy/references/sql/drizzle/mysql.md create mode 100644 .agents/skills/alchemy/references/sql/drizzle/postgres.md create mode 100644 .agents/skills/alchemy/references/sql/effect-sql/d1.md create mode 100644 .agents/skills/alchemy/references/sql/effect-sql/lifecycle.md create mode 100644 .agents/skills/alchemy/references/sql/effect-sql/migrations.md create mode 100644 .agents/skills/alchemy/references/sql/effect-sql/mysql.md create mode 100644 .agents/skills/alchemy/references/sql/effect-sql/postgres.md create mode 100644 .agents/skills/alchemy/references/sql/prisma/contracts.md create mode 100644 .agents/skills/alchemy/references/sql/prisma/migrations.md create mode 100644 .agents/skills/alchemy/references/sql/prisma/postgres.md create mode 100644 .agents/skills/alchemy/references/state-store/INDEX.md create mode 100644 .agents/skills/alchemy/references/state-store/_overview.md create mode 100644 .agents/skills/alchemy/references/state-store/custom-state-store.md create mode 100644 .agents/skills/alchemy/references/stripe/INDEX.md create mode 100644 .agents/skills/alchemy/references/stripe/_overview.md create mode 100644 .agents/skills/alchemy/references/stripe/guides/connect.md create mode 100644 .agents/skills/alchemy/references/stripe/guides/subscriptions.md create mode 100644 .agents/skills/alchemy/references/stripe/guides/webhooks.md create mode 100644 .agents/skills/alchemy/references/stripe/setup.md create mode 100644 .agents/skills/alchemy/references/testing/INDEX.md create mode 100644 .agents/skills/alchemy/references/testing/_overview.md create mode 100644 .agents/skills/alchemy/references/testing/observability.md create mode 100644 .agents/skills/alchemy/references/testing/test-harness.md create mode 100644 .agents/skills/alchemy/references/testing/testing-a-stack.md create mode 100644 .agents/skills/alchemy/references/testing/testing-providers.md create mode 100644 .agents/skills/alchemy/references/what-is-alchemy.md create mode 100644 .agents/skills/alchemy/scripts/build-skill-effects.test.ts create mode 100644 .agents/skills/alchemy/scripts/build-skill.ts create mode 100644 .agents/skills/alchemy/scripts/effect-version.test.ts create mode 100644 .agents/skills/alchemy/scripts/lib/mdx.test.ts create mode 100644 .agents/skills/alchemy/scripts/lib/mdx.ts delete mode 100644 .agents/skills/cloudflare/references/c3/README.md delete mode 100644 .agents/skills/cloudflare/references/c3/api.md delete mode 100644 .agents/skills/cloudflare/references/c3/configuration.md delete mode 100644 .agents/skills/cloudflare/references/c3/gotchas.md delete mode 100644 .agents/skills/cloudflare/references/c3/patterns.md create mode 100644 .agents/skills/oxfmt/SKILL.md create mode 100644 .agents/skills/oxfmt/agents/openai.yaml create mode 100644 .agents/skills/oxfmt/references/languages-sorting-ignores.md create mode 100644 .agents/skills/oxfmt/references/prettier-migration-ci.md create mode 100644 .agents/skills/oxfmt/references/setup-cli-config.md create mode 100644 .agents/skills/oxfmt/references/source-map.md create mode 100644 .agents/skills/oxfmt/references/usage-guide.md create mode 100644 .agents/skills/oxlint/SKILL.md create mode 100644 .agents/skills/tegami/SKILL.md diff --git a/.agents/skills/alchemy/SKILL.md b/.agents/skills/alchemy/SKILL.md index 619be707..6343c245 100644 --- a/.agents/skills/alchemy/SKILL.md +++ b/.agents/skills/alchemy/SKILL.md @@ -1,500 +1,531 @@ --- name: alchemy -description: Configure, develop, deploy, review, and troubleshoot Alchemy v2 infrastructure for Darkmatter TypeScript/Effect apps. Triggers for alchemy.run.ts, alchemy dev, public preview URLs, webhook testing, Cloudflare/AWS providers, stages, profiles, state stores, bindings, CI deploys, or examples from alchemy-run/alchemy-effect. Prefer Alchemy for new org deploys. Do NOT trigger for unrelated blockchain Alchemy APIs unless the user explicitly means alchemy.run. +description: Answers Alchemy (alchemy.run) questions from an indexed local copy of the full documentation. Covers Stacks, Resources, Actions, Outputs, bindings, the Effect-based deploy/runtime phase model, the alchemy CLI, state stores, stages, secret providers (dotenv, Doppler, Infisical), Better Auth, SQL clients (Effect SQL, Drizzle, Prisma ORM), and provider guides for Cloudflare, AWS, Fly, Railway, Hetzner, Neon, PlanetScale, Prisma, Stripe, Docker, and Axiom. Use when the user mentions "alchemy", "alchemy.run", "alchemy.run.ts", "Alchemy.Stack", "Infrastructure-as-Effects", "alchemy deploy", or asks how to define a Resource, bind a resource into a Worker or Lambda, or deploy an Effect program to Cloudflare or AWS with Alchemy. +argument-hint: "[topic]" --- -# Alchemy - -Use this skill when configuring, developing, deploying, or debugging infrastructure with Alchemy v2 and the Effect-native patterns from `alchemy-run/alchemy-effect`. Alchemy treats infrastructure as an Effect program: stacks declare resources, providers supply cloud integrations, local dev can run app code locally against real cloud resources, and deploys converge declared resources into live cloud state. - -Alchemy is the preferred Darkmatter path for new TypeScript/Effect deployable infrastructure. Reach for it before ad hoc provider scripts, hand-written Wrangler/Terraform glue, or one-off deploy workflows unless a repo has an explicit exception or an existing production system that should not be disturbed. - -Alchemy v2 and `alchemy-effect` move quickly. Verify upstream docs before changing deploy or dev code, especially package versions, provider APIs, `alchemy dev` behavior, and CLI flags. This skill carries the upstream `alchemy-run/alchemy-effect` repo as a local submodule at `reference/alchemy-effect` for source, examples, and agent instructions. - -## When to use - -- The user asks to set up or review an `alchemy.run.ts` file. -- The user asks to develop with `alchemy dev`, local Workers, public preview URLs, hot reload, or webhook testing. -- The user asks to configure an Alchemy deploy for Cloudflare Workers, Vite apps, R2, Queues, Durable Objects, D1, AWS Lambda, S3, DynamoDB, ECS, EC2, static sites, or related resources. -- The user asks how to use Alchemy stages, profiles, state stores, bindings, stack outputs, cross-stack references, or CI/CD. -- The user asks to migrate deploy plumbing toward Alchemy v2 or away from ad hoc provider scripts. -- The user asks for examples from `alchemy-run/alchemy-effect/examples`. - -## When NOT to use - -- The request is about the blockchain data/API company Alchemy and not `alchemy.run`. -- The project does not use TypeScript, Effect, Bun/Node, or Alchemy, and the user is only asking a generic deployment question. -- The task is to implement or modify a provider inside the upstream `alchemy-effect` repo. Use the upstream repo's `AGENTS.md` directly, then use this skill only for deploy-configuration implications. -- The task is purely Effect application logic with no deployable infrastructure. Use `effect-typescript`. -- A mature repo already has a production deploy path and the user asks for a narrow bug fix. Preserve the working path unless they explicitly ask to migrate. - -## Upstream check - -Before editing, inspect the current docs or local installed package: - -```bash -bun alchemy --help -bun alchemy dev --help -bun pm ls alchemy effect @effect/platform-bun @effect/platform-node -``` - -Use these upstream references first: - -- `https://v2.alchemy.run/getting-started/` for the current install command, first stack shape, and deploy flow. -- `https://v2.alchemy.run/concepts/stack/` for stack names, outputs, stages, cross-stack references, and state isolation. -- `https://v2.alchemy.run/concepts/local-development/` and `https://v2.alchemy.run/tutorial/part-4/` for `alchemy dev`. -- `https://v2.alchemy.run/concepts/state-store/` for where state is persisted. -- `https://v2.alchemy.run/guides/ci/` for non-interactive deploys and PR preview environments. -- `reference/alchemy-effect/AGENTS.md` for upstream Effect-native infrastructure conventions. -- `reference/alchemy-effect/README.md` for the current project overview and install shape. -- `reference/alchemy-effect/examples/` for concrete app patterns. -- `reference/alchemy-effect/packages/alchemy/src/` for provider/resource implementation patterns. - -If upstream conflicts with this skill, follow upstream and mention the drift. - -If the submodule is missing in a fresh checkout, initialize it before relying on local references: - -```bash -git submodule update --init skills/alchemy/reference/alchemy-effect -``` - -Treat the submodule as read-only during app/deploy work. Edit it only when the user explicitly asks for an upstream `alchemy-effect` contribution, and then follow `reference/alchemy-effect/AGENTS.md` as the repo-local authority. - -## Project setup - -Prefer Bun unless the project already standardizes on another package manager. Current Alchemy v2 docs recommend Bun or Node.js 22+ and install Alchemy with Effect platform packages: - -```bash -bun add "alchemy@2.0.0-beta.40" "effect@>=4.0.0-beta.66 || >=4.0.0" "@effect/platform-bun@>=4.0.0-beta.66 || >=4.0.0" "@effect/platform-node@>=4.0.0-beta.66 || >=4.0.0" -``` - -Pin package versions according to the existing repo policy. If a repo already uses a catalog, workspace protocol, or lockfile-only upgrade lane, follow that instead of pasting the docs command blindly. - -Before adding Alchemy to an existing repo, inspect the current setup: - -```bash -rg -n "alchemy|alchemy.run|alchemy dev|alchemy deploy" package.json bun.lock package-lock.json pnpm-lock.yaml yarn.lock .github scripts . --glob '!node_modules' -find . -name 'alchemy.run.ts' -o -name 'alchemy.run.js' -``` - -If the repo does not already have Alchemy configured, ask the user whether they want Alchemy set up for local development as well as deploys. Do not silently create a deploy-only setup: `alchemy dev` changes the local workflow by provisioning real cloud resources, exposing public preview URLs, and enabling webhook/OAuth callback testing against local code. If the user says yes, include the `dev` script, dev-stage/profile guidance, public preview output handling, and webhook test notes in the first implementation. - -Add scripts that make the stack path explicit when the repo has more than one app: - -```json -{ - "scripts": { - "dev": "alchemy dev ./alchemy.run.ts", - "deploy": "alchemy deploy ./alchemy.run.ts", - "destroy": "alchemy destroy ./alchemy.run.ts", - "deploy:prod": "alchemy deploy ./alchemy.run.ts --stage prod --profile prod" - } -} -``` - -Use plain `bun alchemy deploy` only in tiny repos where `alchemy.run.ts` at the -root is unambiguous. Prefer the repo's per-app script when the app owns one -(for example `bun run deploy:prod`), and check that app's README or run config -for required environment. For Vite apps, build `client` before `ssr` so the SSR -build can consume the hydration manifest. - -## Org default - -For Darkmatter TypeScript/Effect projects, Alchemy is the deploy and dev -control plane: - -- New Cloudflare Workers, Vite/frontends, queues, R2/KV, workflows, AI Gateway, tunnels, and GitHub deploy automation should start with Alchemy. -- Alchemy owns deploy wiring, bindings, secrets, and network resources. It does - not choose application persistence: follow - [darkmatter-ts-toolchain](../darkmatter-ts-toolchain/SKILL.md) for - Postgres-first application data, and do not use a Cloudflare-native database - as a shortcut around that policy. -- New AWS Lambda, S3, DynamoDB, API Gateway, ECS/EC2/EKS/RDS, and static-site deploys should consider Alchemy first. -- Shared preview environments should use Alchemy stages instead of bespoke name mangling. -- Local webhook testing should use `alchemy dev` or an Alchemy-managed tunnel/preview flow instead of separate ngrok/Cloudflare Tunnel scripts when Alchemy can provide the route. -- Hand-written provider CLIs can still be useful for diagnostics, but should not become the durable deploy control plane without a reason. - -## Stack shape - -Every deploy file should default-export `Alchemy.Stack(name, options, Effect.gen(...))`. - -```ts +# alchemy documentation index + +Answer the Alchemy question in `$ARGUMENTS` (or, if empty, the request that +triggered this skill) using the indexed corpus below. All paths are relative to +this skill's directory. + + + +Indexed from `alchemy-run/alchemy` @ `e354a45` (2026-09-30), alchemy `2.0.0-beta.79` on `effect@4.0.0-rc.118`: **484 topic files** across 28 areas. + +Reference paths mirror site URLs exactly, so a path is derivable without +searching: `https://alchemy.run/cloudflare/compute/workers` -> +`references/cloudflare/compute/workers.md`. A section landing page is +`_overview.md` (`https://alchemy.run/cli` -> `references/cli/_overview.md`). + + + +## Task + +1. **Classify** the question against the routing tables below. +2. **Read** at most **1-3** topic files. Prefer one exact file over many. +3. **Answer** with concrete TypeScript, using exact symbol names. +4. **Cite** each reference file you used by path. + +If _Core mental model_ or _Command surface_ already covers it, answer directly +and read nothing. + +## Core mental model + +- **`Alchemy.Stack(name, { providers, state }, effect)`** is the deploy unit. + `providers` and `state` are both required; the `Effect.gen`'s return value is + the stack output. It is `export default`ed. `alchemy.run.ts` is only the CLI's + default lookup, so any file with a default-exported Stack works as `[file]`. +- **Resource.** The first constructor argument is the **logical ID** you choose. + The physical name is derived as `{stack}-{stage}-{logicalId}-{instanceId}`. + The instance ID is stable across create/update/delete and changes only on + replacement, which is what makes creates idempotent. A resource declaration is + **inert until a Stack `yield*`s it**, so declaring one at module scope and + importing it deploys nothing. +- **`Output`** is the lazy typed reference. Property access returns another + Output (`bucket.bucketName` is `Output`), and passing one into another + resource's props is what registers the dependency edge that orders the deploy. + Operators: `Output.all`, `` Output.interpolate`...` ``, + `output.pipe(Output.map(fn))`, `Output.mapEffect`, `Output.literal`, + `Output.fromEffect`, `Output.ref`. `Redacted` survives evaluation and prints + as ``. +- **Bindings are the thing that makes v2 different from prop-passing IaC.** + `yield* Cloudflare.R2.ReadWriteBucket(Bucket)` hands back a typed SDK client. + There is no `env.BUCKET` and no hand-written IAM. A binding has two halves: the + **contract** (a `Binding.Service`, a callable Context tag) and an + interchangeable **implementation Layer** supplied with `Effect.provide`: + `*Binding` (native platform binding), `*Http` (deploy-time scoped token plus + HTTP at runtime), `*Local` (deploy-time client reusing the CLI's credentials, + for use inside Actions). Same contract, same handler code. Bindings are also + the only mechanism that expresses **circular references**; plain Output props + form a DAG. +- **Providers** teach Alchemy a resource type and are Effect Layers: + `Cloudflare.providers()`, `AWS.providers()`, `Layer.mergeAll(...)`. Declaring a + resource whose provider layer is missing is a **type error**. +- **Lifecycle:** Diff, Read, Pre-Create, Reconcile (used for both create and + update), Delete (must be idempotent). On a resource with no prior state the + engine calls `read`: `undefined` means create, plain attrs means silent adopt + (state recovery), `Unowned(attrs)` fails unless `--adopt`. +- **Actions** are graph nodes with no provider lifecycle: no replace, read, or + delete. Only run (`λ`) or skip (`·`), decided by a SHA-256 of the resolved + input against the persisted `inputHash`. `deploy --force` flips every skip to + run. Use them for seeding, migrations, and cache invalidation. +- **State** persists by FQN, scoped by stack name and stage: + `Alchemy.localState()` (`.alchemy/` on disk), `Cloudflare.state()` (Worker plus + Durable Object), `AWS.state()` (S3). **Stage** is an isolated instance of a + Stack (default `dev_$USER`) with its own state and physical names. + +### The two phases + +Function-shaped resources (Worker, Lambda, Durable Object, Workflow, Container, +ECS Task, Fly/Railway/Hetzner Service) express both phases in one program by +returning an Effect from inside an Effect: + +- The **outer `Effect.gen` is the init phase.** It runs at plantime to discover + and record bindings, and _again at cold start_ inside the deployed handler, + where the same calls return live SDK clients. Bind what you need, return what + you expose. +- The **returned handlers are the runtime phase** (`fetch` plus any RPC methods), + running per request and never at plantime. +- `Alchemy.RuntimeContext` exists only in the runtime closure. Requiring it + outside a handler is a compile error. +- `ALCHEMY_PHASE` is `plan` or `runtime`. `alchemy dev` is a **plantime** phase, + so read the `ALCHEMY_DEV` config key to detect dev specifically. +- Two lifetimes: instance scope (once per isolate) and request scope (per event). + **Build at instance scope, acquire at request scope**, because instance + finalizers never run on workerd. + +Effect style is optional. **Async style** keeps a plain `async fetch(request, +env)` handler, declares bindings on the resource's `env` prop, and types `env` +with `Cloudflare.InferEnv`. Both styles share providers, CLI, and +artifact. + +## Canonical shapes + +```typescript +// alchemy.run.ts import * as Alchemy from "alchemy"; import * as Cloudflare from "alchemy/Cloudflare"; import * as Effect from "effect/Effect"; export default Alchemy.Stack( "MyApp", - { - providers: Cloudflare.providers(), - state: Cloudflare.state(), - }, + { providers: Cloudflare.providers(), state: Cloudflare.state() }, Effect.gen(function* () { - const bucket = yield* Cloudflare.R2Bucket("Bucket"); - - return { - bucketName: bucket.bucketName, - }; + const bucket = yield* Cloudflare.R2.Bucket("Bucket"); + return { bucketName: bucket.bucketName }; }), ); ``` -The load-bearing pieces are: - -- Stack name: stable logical deployment unit. Do not casually rename it; state and names derive from it. -- Providers: cloud auth and provider implementations, for example `Cloudflare.providers()` or `AWS.providers()`. -- State: choose deliberately. Cloudflare deploys commonly use `Cloudflare.state()`; simple AWS examples may use `Alchemy.localState()`. -- Effect body: declare resources with `yield*`, compose dependencies through references and bindings, and return useful outputs for smoke checks. - -## Provider module layout - -Follow the "Readability and module design" section in the repository's -AGENTS.md. A provider is one capability: keep the reconcile flow and the -mapping, status, and cleanup helpers it needs together. Inspect the installed -Alchemy version and pinned upstream provider before choosing API names or -registration shapes. Upstream contributions follow that repository's own -layout. - -For a provider implemented in a Darkmatter app or package: - -- Preserve Alchemy's resource/provider collection, binding, and public-entry - contracts. Public composition may register several resource layers without - re-exporting every internal helper through a chain of barrels. -- A shared client owns API requests, response decoding, and typed failures. Use - an Effect-native SDK directly; wrap a Promise SDK only at that edge. Keep - configuration and credentials in the existing config/provider layers. -- Test provider behavior through the resource or provider interface. Coverage - decisions follow [when-to-write-tests](../when-to-write-tests/SKILL.md). - -## Local development - -Use `alchemy dev` for the default local loop when the app can run through Alchemy: - -```bash -bun alchemy dev ./alchemy.run.ts -``` - -Alchemy dev mode deploys infrastructure to the real cloud provider, runs supported app code locally, and hot reloads on file changes. For Cloudflare Workers, the Worker runs locally in `workerd` while a cloud proxy routes requests between public cloud endpoints and the local process. - -This matters for webhook work: external services need a public URL, while the developer needs local code, breakpoints, and hot reload. Prefer an Alchemy dev/preview URL for webhook callbacks when available, then configure the third-party webhook target to that URL for the session or preview stage. - -Use explicit dev ports when the app has multiple local services or a fixed callback URL: - -```ts -export const Worker = Cloudflare.Worker("Worker", { - main: "./src/worker.ts", - dev: { - port: 3000, - }, -}); -``` - -Dev-mode rules: - -- Treat cloud resources created by `alchemy dev` as real resources, not emulators. -- Keep stage/profile explicit when working near shared accounts. -- Return local/public URLs from stack outputs so the agent can test the live route. -- Verify webhook flows with the provider's actual delivery logs when available. -- Stop the dev process cleanly; destroy dev/preview resources when they are no longer needed and the stack owns them. - -## Stage and profile rules - -Treat stage and profile as part of the deployment contract: - -- Stage isolates state and physical names. The default is developer-specific; use explicit `--stage prod`, `--stage staging`, or `--stage pr-123` for shared environments. -- Profile selects credentials. First deploy may prompt interactively and save credentials under `~/.alchemy/profiles.json`; non-interactive CI must have profiles or provider credentials prepared before deploy. -- Production scripts should pass both `--stage prod` and the intended `--profile`. -- Preview deploys should derive a deterministic stage from the PR, branch, or issue id and should destroy that stage when it is no longer needed. - -Useful commands: - -```bash -bun alchemy deploy ./alchemy.run.ts --stage dev_cm -bun alchemy deploy ./alchemy.run.ts --stage prod --profile prod -bun alchemy destroy ./alchemy.run.ts --stage pr-123 --profile preview -alchemy login --configure -alchemy login --profile prod --configure -``` - -Never use `Date.now()` or random timestamps in physical names. Let Alchemy generate names from stack, stage, and logical id, or use a deterministic name tied to the environment. - -## Bindings and runtime dependencies - -Prefer bindings over manually threading deployed identifiers through environment variables. A binding is deploy-time data attached to a runtime resource so the function/worker receives exactly the infrastructure dependency it needs. - -For Cloudflare, bind platform resources directly into a Worker or Vite app: - -```ts -export const Bucket = Cloudflare.R2Bucket("Bucket"); - -export const Worker = Cloudflare.Worker("Worker", { - main: "./src/worker.ts", - bindings: { - Bucket, - }, -}); -``` - -Keep Postgres connection details in the app's config/secrets layer and expose -the runtime client through a service Layer. Only bind a database-like Cloudflare -resource when the repo has an explicit reason not to use Postgres for that data. - -For Effect-native runtimes, keep runtime services and deploy wiring separate. Alchemy owns cloud resources and bindings; Effect services own runtime behavior. Use Layers at the stack boundary when the resource requires a runtime implementation. - -### Capability shape: `Binding.Service` + `Binding.Policy` - -Every Alchemy capability ships as a pair of layers: - -- `Binding.Service` — runtime SDK wrapper, provided on the **Function/Worker** Effect (bundled into the deployed artifact). -- `Binding.Policy` — deploy-time IAM/binding attachment, provided on the **Stack** via `AWS.providers()()`, never bundled. - -For Cloudflare, the worker `bindings: { ... }` field handles both sides automatically. For AWS, the Service layer goes on the Function and the Policy layer must be enabled at the stack level. The default `AWS.providers()` already enables the common policy layers; only reach for `AWS.providers()(extraLayers)` when you author or override a capability. - -### `Alchemy.RuntimeContext` on runtime-only methods - -Bindings expose a typed callable whose inner Effect carries an `Alchemy.RuntimeContext` requirement. Treat that as a colored function: - -- The **outer** Effect (setup at function init) does NOT require `RuntimeContext`. -- The **inner** Effect (the actual SDK call) MUST require `RuntimeContext` and only runs inside a deployed Function/Worker. - -If a service wraps a binding and accidentally leaks `WorkerEnvironment` / `Lambda.FunctionEnvironment` into its interface, that service becomes cloud-coupled. Resolve cloud env services once during Layer construction and close over them — do not expose them as requirements on consumer Effects. - -## Effect platform rules in app/runtime code - -Alchemy stacks and Worker/Function bodies run as Effect programs. General -Effect service, error, and config patterns are in -[effect-typescript](../effect-typescript/SKILL.md); the rules below apply them -to stack bodies, custom resource helpers, and tests. Do not reach for raw -async or Node primitives inside `Effect.gen`: - -| Don't | Do | -| ------------------------------------------- | ---------------------------------------------------- | -| `import fs from "node:fs/promises"` | `const fs = yield* FileSystem.FileSystem` | -| `await fs.readFile(p, "utf8")` | `yield* fs.readFileString(p)` | -| `import path from "pathe"` / `node:path` | `const path = yield* Path.Path` | -| `await fetch(...)` | `yield* HttpClient.HttpClient` + `HttpClientRequest` | -| `new Promise((res) => setTimeout(res, ms))` | `yield* Effect.sleep(Duration.millis(ms))` | -| `Effect.promise(() => listSqlFiles(dir))` | Make the helper itself return an `Effect` | - -Pure transformations do not need Effect wrappers. Suspend environment reads -and side effects in Effect; classify expected thrown failures when crossing an -external boundary. Wrapping a CPU calculation does not make that calculation -interruptible. Keep internal operations in Effect once a driver has been adapted, -and run them only at the host or runtime boundary that requires it. - -This applies to **stack bodies, custom resource helpers, and tests**. Tests must use `FileSystem.FileSystem` / `Path.Path` for any file/path access. - -Polling rules for deploy/runtime tests: - -- Use `Effect.repeat` with `Schedule` + an `until` predicate and a bounded `times: N` cap. -- Do not write `while (Date.now() < deadline)` loops — they ignore interruption and leak into the vitest timeout. - -```ts -// good — declarative, bounded, interruption-safe -const value = - yield * - fetchValue.pipe( - Effect.repeat({ - schedule: Schedule.spaced("5 seconds"), - until: (v) => v.ready, - times: 36, - }), - ); -``` - -## Provider lifecycle tests - -When authoring custom Alchemy providers/resources, add provider-style tests with `alchemy/Test/Vitest` rather than only unit-testing helper functions. These tests exercise the real Alchemy plan/apply engine, private scratch state, repeated deploys, and destroy behavior in one Effect program. - -Use this shape for resource lifecycle tests: - -```ts -import * as Test from "alchemy/Test/Vitest"; -import { expect } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as ProviderNamespace from "../src/ProviderNamespace/index.ts"; - -const { test } = Test.make({ providers: ProviderNamespace.providers() }); - -test.provider("create, update, and delete resource", (stack) => +```typescript +// src/worker.ts -- init phase binds, runtime phase serves +export default Cloudflare.Worker( + "Api", + { main: import.meta.url }, Effect.gen(function* () { - const client = yield* ProviderNamespace.Client; - - const created = yield* stack.deploy( - Effect.gen(function* () { - return yield* ProviderNamespace.Resource("TestResource", { - name: "v1", - }); - }), - ); - expect(created.resourceId).toBeDefined(); - - // Verify live provider state through the provider SDK/client, not only - // through returned attributes. - const live1 = yield* client.get(created.resourceId); - expect(live1.name).toBe("v1"); - - const updated = yield* stack.deploy( - Effect.gen(function* () { - return yield* ProviderNamespace.Resource("TestResource", { - name: "v2", - }); + const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket); + return { + fetch: Effect.gen(function* () { + const obj = yield* bucket.get("hello.txt"); + return obj + ? HttpServerResponse.text(yield* obj.text()) + : HttpServerResponse.text("Not found", { status: 404 }); }), - ); - expect(updated.resourceId).toBe(created.resourceId); - - const live2 = yield* client.get(updated.resourceId); - expect(live2.name).toBe("v2"); - - yield* stack.destroy(); - }), + }; + }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)), ); ``` -Provider test rules: - -- Configure providers once per file with `const { test } = Test.make({ providers: Namespace.providers() })`. -- Prefer real provider SDK/client assertions for integration tests. If live credentials or real resources are expensive/dangerous, use a fake service layer but still run through `test.provider`, `stack.deploy(...)`, repeated deploy, and `stack.destroy()`. -- Test create → update/noop/replace as appropriate → destroy in the same `test.provider` body so Alchemy state is shared across deploys. -- Use the scratch stack handed to the test; do not write to `.alchemy/` or shared state from provider tests. -- Provider implementations should close over services at provider construction (`const client = yield* Client` before returning lifecycle methods), matching Cloudflare providers. Do not leave `yield* Client` requirements inside `read`/`diff`/`reconcile`/`delete` unless the effect is explicitly provided there; `test.provider` should catch this with a “Service not found” failure. -- For live provider tests that need credentials, resolve them through the same AuthProvider/config path as `alchemy deploy` (for example env-method credentials or an `alchemy login` test profile). Skip or gate the test when credentials are absent; do not commit secrets. -- Preserve useful public-contract tests, including pure calculations. Coverage - decisions follow [when-to-write-tests](../when-to-write-tests/SKILL.md); - provider tests still verify real lifecycle semantics. - -## Build and type checking - -Before committing changes that touch a stack, custom resource, or runtime -binding, run the repository's checks from -[darkmatter-ts-toolchain](../darkmatter-ts-toolchain/SKILL.md): `typecheck` -(tsgo, not `tsc`), tests, lint, and format. - -For direct `alchemy-effect` upstream contributions, follow that repository's -commands instead. `bun tsc -b` runs the workspace project-reference build, and -CI fails on type errors. When stale artifacts or dependency drift cause -unexplained failures, a clean rebuild is the recovery path: - -```bash -bun run build # clean + tsc + build the alchemy package -bun build:clean # full reset: clean . + bun i + build + download env -``` +The class form adds RPC methods and a nominal type; the `` self +reference and the empty `()` are required ceremony: -`bun build:clean` removes untracked files (preserving `.env`), reinstalls -dependencies, rebuilds, and refreshes environment files. Treat it as a recovery -command, not a routine one. - -## Secrets and config - -Do not commit provider tokens, API keys, generated profiles, or state files -unless the repo explicitly owns a remote state backend and the file is designed -for version control. - -Use these rules: - -- Prefer provider profiles or CI secrets for deploy credentials. -- Use Alchemy secret resources or provider-native secret resources for runtime - secrets. -- Keep non-secret config out of secret resources. Runtime configuration comes - from named config files read through `effect/Config`; the general named-config - procedure is in - [effect-typescript](../effect-typescript/SKILL.md#named-configuration). On - Workers, bundle the named configs and let the deploy set only the selector - variable (`APP_CONFIG`). -- Secret access procedure is - [sops-secret-access](../sops-secret-access/SKILL.md). Route durable secrets - through the repo's existing SOPS/Himitsu/CI path instead of inventing a new - `.env` convention. -- Deploy-time secrets commonly come from `himitsu read `. Alchemy's - config schema requires `SOPS_AGE_KEY` explicitly in the environment and does - not fall back to `~/.config/sops/age/keys.txt`: - `SOPS_AGE_KEY=$(grep '^AGE-SECRET-KEY-' ~/.config/sops/age/keys.txt | head -1)`. -- Document which profile, account, zone, region, or project owns each deploy - target. - -## CI deploys - -CI should be deterministic and non-interactive: - -1. Install dependencies with the repo's locked package manager command. -2. Run type checks before deploy. -3. Authenticate the provider without a browser prompt. -4. Deploy with explicit stack path, stage, and profile. -5. Print or capture stack outputs for smoke tests. -6. Destroy preview stages on branch/PR cleanup when the workflow owns temporary infrastructure. - -Example CI command shape: - -```bash -bun install --frozen-lockfile -bun run typecheck -bun alchemy deploy ./alchemy.run.ts --stage "$ALCHEMY_STAGE" --profile "$ALCHEMY_PROFILE" +```typescript +export default class Greeter extends Cloudflare.Worker()( + "Greeter", + { main: import.meta.url }, + Effect.gen(function* () { + return { greet: (name: string) => Effect.succeed(`hello ${name}`) }; + }), +) {} ``` -If a deploy failure starts in provider auth, profile lookup, state lock/state persistence, or missing CI secrets, fix that layer before changing resource code. - -## Preview URLs and webhooks - -Public previews are an expected Alchemy workflow: +## Command surface + +`bun alchemy [options]`. The stack file defaults to `alchemy.run.ts` +and must exist. Pass another with `--config`/`-c `, or positionally as +`[file]` on `deploy`, `plan`, `destroy`, `dev`, and `unsafe nuke` only. Stack +commands take `--stage`, `--profile`, and `--env-file`. `--yes`/`-y` exists only +where a command asks for approval (`deploy`, `destroy`, `unsafe nuke`, +`provider aws teardown`, `provider cloudflare token`); `plan`, `dev`, `logs`, and +`drift` reject it. The global `--no-input` disables prompts and the TUI. + +| Command | Purpose | Flags that matter | +| ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `deploy [file]` | plan, approve, apply | `--dry-run`, `--force`, `--adopt`, `--yes` | +| `plan [file]` | preview only; same code path as `deploy --dry-run` | `--stage`, `--detailed`, `--adopt` | +| `destroy [file]` | delete this stack and stage in dependency order | `--dry-run`, `--yes` | +| `drift` | re-observe the cloud and report drift; asks before repairing unless `--repair` | `--repair` (repairs without asking; there is no `--yes`) | +| `unsafe nuke [file]` | delete everything each provider's `list()` returns | `--dry-run`, `--include`, `--exclude`, `--filter`, `--local` | +| `dev [file]` | hot-reloading loop; implies `--yes` and dev mode | `--force` | +| `logs` | fetch past log entries, or stream live with `--tail` | `--resource`/`-r `, `--tail`/`-t`, `--limit`, `--since` | +| `profile [create\|rename\|edit\|refresh\|current\|list\|show\|delete]` | manage credentials in `~/.alchemy/profiles.json`; bare `profile` opens the dashboard | `--add`, `--reconfigure`, `--remove` (on `edit`) | +| `state [list\|read\|delete]` | inspect and manage state (aliases `ls`/`cat`/`rm`); bare `state` opens the explorer | `--backend `, `--recursive`/`-r` | +| `provider check-env` | CI preflight: verify the stack's provider env vars are set (exits 1 if any are missing) | `--provider` | +| `provider aws bootstrap\|teardown` | per-account assets bucket that Lambda needs | `--aws-profile`, `--region`, `--yes` (teardown only) | +| `provider cloudflare bootstrap\|teardown\|token\|state logs` | state-store worker, API tokens, its logs | `--force` (bootstrap), `--all-permissions` (token), `--tail`/`-t` (state logs) | + +Plan symbols: `+` create, `~` update, `±` replace, `-` delete, `•` no-op, `λ` +action run, `·` action skip. + +`deploy`, `destroy`, and `plan` render an Ink TUI interactively. A known agent +env var (including `CLAUDECODE`) forces plain output, and **plain mode never +prompts and makes no changes**, so always pass `--yes` when running these +yourself. + +## Routing + + + +### Start here + +- `references/getting-started.md` -- Install Alchemy and create your first Stack in under two minutes. +- `references/migrating-from-v1.md` -- Migrate your Alchemy v1 (async/await) project to Alchemy v2. +- `references/what-is-alchemy.md` -- Alchemy is Infrastructure as Code built in pure Effect, with Infrastructure as Effects on top. Declare your cloud resources and the code that runs on them in one type-safe TypeScript program, and deploy it with one command. + +### Areas + +- `references/acme/` (8) -- Manage TLS certificates independently of your hosting provider, at deploy time or from an application. +- `references/apis/` (4) -- Every Function and Server returns { fetch, ...rpcs } — schemaless typed calls are the default for internal communication; Effect RPC and Effect HTTP add schemas where data crosses a trust boundary. +- `references/aws/` (54) -- Build AWS applications with Alchemy — a runtime (usually Lambda) plus typed resources, wired together by bindings that mint least-privilege IAM policies. +- `references/axiom/` (6) -- Observability as resources — OTEL datasets, ingest tokens, monitors, notifiers, and dashboards declared next to the code that emits the data. +- `references/better-auth/` (28) -- Build authentication with an Effect service, automatic database bindings, and deploy-time migrations. +- `references/cli/` (14) -- Every alchemy command at a glance — the command map, common options, and how the interactive TUI decides when to render. +- `references/cloudflare/` (78) -- Build and deploy full applications on Cloudflare with Alchemy — one Worker runtime plus resources like Durable Objects, D1, R2, Queues, and Hyperdrive, wired together by typed bindings. +- `references/command/` (3) -- Alchemy's cloud-agnostic primitives for local processes in the deploy graph — memoized builds, one-off commands, and dev servers. +- `references/docker/` (5) -- Images, containers, networks, and volumes as Stack resources, driven through your active Docker CLI context. +- `references/environments/` (10) -- Stages, per-environment config, secrets, local dev, and CI. +- `references/fly/` (35) -- Deploy Effect programs to Fly.io as Apps, Machines, Services, and Sprites. +- `references/gcp/` (6) -- Google Cloud in one Effect program — Cloud Run services and jobs, Firestore, Pub/Sub, BigQuery, Secret Manager, and Memorystore, with IAM granted by the bindings themselves. +- `references/git/` (25) -- A pluggable, embeddable, self-hostable git server on Cloudflare Workers, Durable Objects, and R2. Smart HTTP for any client, a typed REST plane with pull requests, and a GitHub-compatible API. +- `references/github/` (5) -- Repositories, Actions secrets and variables, webhooks, and repository event sources as Stack resources — the glue for CI/CD. +- `references/hetzner/` (27) -- Build applications on Hetzner Cloud with Alchemy — Servers running your Effect programs as Services, plus volumes, networks, firewalls, load balancers, and DNS, all in one typed program. +- `references/infrastructure-as-code/` (10) -- The noun graph: Stacks, Resources, Actions, Outputs, references, lifecycle, providers. +- `references/infrastructure-as-effects/` (9) -- One Effect program models runtime code and infrastructure: Runtimes carry code, Bindings wire resources into them, Phases split deploy from runtime, and Layers package it behind typed services. +- `references/kubernetes/` (19) -- Run containers and Effect programs on any Kubernetes cluster with Alchemy. Declare Deployments, Jobs, raw manifests, and Helm charts in the same typed program as the rest of your infrastructure. No YAML, no kubectl apply. +- `references/neon/` (31) -- Declare Neon Postgres, Functions, storage, Auth, AI Gateway and websites together in an Alchemy Stack. +- `references/planetscale/` (9) -- Serverless MySQL (Vitess) and Postgres with database branching — databases, branches, and credentials as Stack resources. +- `references/prisma/` (29) -- Prisma Postgres and Prisma Compute — projects, databases, connections, and deployed apps as Stack resources, with a zero-config local database in dev. +- `references/project-structure/` (4) -- How to lay out single-stack and multi-stack repos. +- `references/railway/` (36) -- Deploy Effect programs to Railway as Projects, Services, databases, Volumes, and Buckets. +- `references/sql/` (14) -- Choose a database, connect with Effect SQL, Drizzle, or Prisma ORM, and deploy committed migrations. +- `references/state-store/` (2) -- How Alchemy persists resource state between deploys to compute diffs and track infrastructure. +- `references/stripe/` (5) -- Sell subscriptions and onboard merchants from a Cloudflare Worker — catalog, Checkout, the Billing Portal, Connect, and webhooks declared next to the code that uses them. +- `references/testing/` (5) -- How Alchemy tests work — real clouds by default, local emulators on demand, one Stack deploy per suite, isolated stages, deploy → assert → destroy. + +Each area has an `INDEX.md` listing every page with what it covers. Read +it only to disambiguate; read the topic file to answer. + +### Page map + +Every page in the corpus. Append `.md` and prefix `references//`. -- PR deploys should use `--stage pr-` and post the resulting app URL back to the PR when the repo has GitHub access. -- Local development should use `alchemy dev` when a webhook or collaborator needs to reach a public URL backed by local code. -- Preview URLs should be derived from stack outputs or provider resources, not guessed from PR numbers. -- Webhook tests should exercise the real external sender when feasible, not only `curl`. -- Cleanup should destroy preview stages on PR close/merge and retire temporary webhook registrations. - -## Verification - -Always verify the real deployed target when the request concerns deploy behavior: - -```bash -bun alchemy deploy ./alchemy.run.ts --stage "$STAGE" --profile "$PROFILE" -curl -fsS "$DEPLOYED_URL" ``` - -For local dev behavior, run the dev server and hit both local and public/proxied URLs when Alchemy prints both: - -```bash -bun alchemy dev ./alchemy.run.ts --stage "$STAGE" --profile "$PROFILE" -curl -fsS "$LOCAL_URL" -curl -fsS "$PUBLIC_PREVIEW_URL" +acme/ _overview certificate-authorities dns-validation + getting-started renewal runtime troubleshooting + using-certificates +apis/ _overview effect-http effect-rpc schemaless +aws/ _overview ai/bedrock apis/api-gateway apis/effect-http-api + apis/effect-rpc apis/schemaless-rpc + compute/choosing-a-runtime compute/ec2 compute/ecs + compute/eks compute/hyperpod compute/lambda compute/microvms + data/drizzle-aurora data/drizzle-dsql data/dynamodb data/rds + data/s3 email/receiving email/sending frontend/astro + frontend/foldkit frontend/full-stack-tanstack-rpc-drizzle + frontend/nextjs frontend/nuxt frontend/octane + frontend/react-router frontend/solidstart + frontend/static-site frontend/sveltekit + frontend/tanstack-start frontend/vinext frontend/vite-spa + frontend/vite frontend/vue frontend/waku frontend/websites + local-development messaging/dynamodb-streams + messaging/eventbridge messaging/kinesis messaging/s3-events + messaging/sns messaging/sqs networking/_overview + networking/custom-domains observability/cloudwatch + security/secrets-env setup tutorial/part-1 tutorial/part-2 + tutorial/part-3 tutorial/part-4 tutorial/part-5 +axiom/ _overview data/ingest guides/alerting guides/annotations + guides/dashboards setup +better-auth/ _overview databases/_overview databases/aurora-data-api + databases/cloudflare-d1 databases/cloudflare-hyperdrive + databases/drizzle databases/memory databases/mysql + databases/neon databases/postgres databases/sqlite + guides/configuration guides/http-api-middleware + guides/migrations guides/secondary-storage + sign-in-providers/_overview sign-in-providers/custom-oauth + sign-in-providers/email-password sign-in-providers/github + sign-in-providers/google sign-in-providers/microsoft + tutorial/part-1 tutorial/part-2 tutorial/part-3 + tutorial/part-4 tutorial/part-5 tutorial/part-6 + upgrades/from-1-6-to-1-7 +cli/ _overview adopting-resources aws cloudflare deploy destroy + dev drift inspecting-state logs nuke plan profile state +cloudflare/ _overview ai/ai-gateway ai/ai-search ai/effect-ai + ai/release-agent ai/vectorize ai/workers-ai + apis/effect-http-api apis/effect-rpc apis/schemaless-rpc + compute/add-a-workflow compute/browser-rendering + compute/cache compute/containers + compute/cross-worker-durable-object compute/durable-objects + compute/gradual-deployments compute/hibernatable-websockets + compute/previews compute/python-workers compute/rate-limiting + compute/run-a-container compute/worker-loader + compute/workers-for-platforms compute/workers + compute/workflows data/artifacts + data/branch-from-shared-database data/d1-drizzle data/d1 + data/drizzle data/hyperdrive data/kv data/prisma + data/r2-presigned-urls data/r2 data/shared-database + email/_overview email/email-worker email/send-and-receive + frontend/astro frontend/foldkit frontend/frontends + frontend/full-stack-tanstack-rpc-drizzle frontend/nextjs + frontend/nuxt frontend/octane frontend/react-router + frontend/solidstart frontend/static-site frontend/sveltekit + frontend/tanstack-start frontend/vinext frontend/vite-spa + frontend/vite frontend/vue frontend/waku local-development + messaging/cron messaging/github-events messaging/queues + networking/custom-domains networking/domains + networking/tunnel observability/analytics-engine + observability/axiom-observability + observability/workers-tracing security/access + security/secrets-env security/secrets-store + security/turnstile setup tutorial/part-1 tutorial/part-2 + tutorial/part-3 tutorial/part-4 tutorial/part-5 + tutorial/part-6 +command/ _overview dev-servers memoization +docker/ _overview build-and-push local-services setup swarm +environments/ auth-providers ci custom-auth-provider doppler infisical + local-development profiles secret-providers secrets stages +fly/ _overview compute/apps compute/connecting-services + compute/deployments compute/machines compute/regions + compute/services compute/sprites data/drizzle-postgres + data/postgres data/redis data/secrets data/tigris + data/volumes frontend/astro frontend/foldkit frontend/nextjs + frontend/nuxt frontend/octane frontend/react-router + frontend/solidstart frontend/static-site frontend/sveltekit + frontend/tanstack-start frontend/vinext frontend/vite + frontend/vocs frontend/waku frontend/websites networking + setup tutorial/part-1 tutorial/part-2 tutorial/part-3 + tutorial/part-4 +gcp/ _overview guides/bindings guides/cloud-run-api + guides/event-pipeline guides/memorystore setup +git/ _overview blocks/_overview blocks/auth blocks/blob-store + blocks/engine blocks/hasher blocks/registry + blocks/repositories blocks/server clone-and-push + getting-started github-api pull-requests recipes/_overview + recipes/cloudflare-aws recipes/cloudflare recipes/scaling + recipes/your-own-store repositories tutorial/part-1 + tutorial/part-2 tutorial/part-3 tutorial/part-4 + tutorial/part-5 tutorial/part-6 +github/ _overview actions-config events repository setup +hetzner/ _overview compute/servers compute/services + data/drizzle-postgres data/volumes frontend/astro + frontend/foldkit frontend/nextjs frontend/nuxt + frontend/octane frontend/react-router frontend/solidstart + frontend/static-site frontend/sveltekit + frontend/tanstack-start frontend/vinext frontend/vite + frontend/vocs frontend/waku frontend/websites + networking/_overview networking/dns setup tutorial/part-1 + tutorial/part-2 tutorial/part-3 tutorial/part-4 +infrastructure-as-code/ action custom-provider local-provider outputs provider + references renaming resource-lifecycle resource stack +infrastructure-as-effects/ binding circular-bindings custom-runtime event-sources layers + phases runtime sinks telemetry +kubernetes/ _overview clusters/cluster-adapters clusters/connecting + clusters/eks clusters/local clusters/registries + objects/helm-charts objects/manifests setup tutorial/part-1 + tutorial/part-2 tutorial/part-3 tutorial/part-4 + tutorial/part-5 workloads/bindings workloads/deployments + workloads/images workloads/jobs workloads/object-lifecycle +neon/ _overview data/branching data/connections data/migrations + frontend/astro frontend/foldkit frontend/nextjs frontend/nuxt + frontend/octane frontend/react-router frontend/solidstart + frontend/static-site frontend/sveltekit + frontend/tanstack-start frontend/vite frontend/vocs + frontend/waku governance guides/ai-gateway + guides/custom-domains guides/drizzle guides/preview-branches + guides/private-networking guides/production-auth + guides/state-recovery setup tutorial/_overview + tutorial/backend tutorial/frontend tutorial/functions + tutorial/previews +planetscale/ _overview data/backups data/credentials data/migrations + data/mysql data/postgres guides/drizzle + guides/preview-branches setup +prisma/ _overview compute/apps compute/deployments data/branches + data/buckets data/connections data/drizzle-postgres + data/postgres frontend/astro frontend/foldkit frontend/nextjs + frontend/nuxt frontend/octane frontend/react-router + frontend/solidstart frontend/static-site frontend/sveltekit + frontend/tanstack-start frontend/vinext frontend/vite + frontend/vocs frontend/waku frontend/websites + guides/cloudflare-workers setup tutorial/part-1 + tutorial/part-2 tutorial/part-3 tutorial/part-4 +project-structure/ file-layout monorepo-multi-stack monorepo-single-stack + monorepo +railway/ _overview compute/environments compute/functions + compute/projects compute/regions compute/services + data/buckets data/drizzle-mysql data/drizzle-postgres + data/mongo data/mysql data/postgres data/redis data/variables + data/volumes frontend/astro frontend/foldkit frontend/nextjs + frontend/nuxt frontend/octane frontend/react-router + frontend/solidstart frontend/static-site frontend/sveltekit + frontend/tanstack-start frontend/vinext frontend/vite + frontend/vocs frontend/waku frontend/websites networking + setup tutorial/part-1 tutorial/part-2 tutorial/part-3 + tutorial/part-4 +sql/ _overview databases drizzle/d1 drizzle/migrations + drizzle/mysql drizzle/postgres effect-sql/d1 + effect-sql/lifecycle effect-sql/migrations effect-sql/mysql + effect-sql/postgres prisma/contracts prisma/migrations + prisma/postgres +state-store/ _overview custom-state-store +stripe/ _overview guides/connect guides/subscriptions guides/webhooks + setup +testing/ _overview observability test-harness testing-a-stack + testing-providers ``` -For Effect-native workers/functions, prefer a deployed fixture or smoke route over a purely local assertion. Fresh Workers, Lambda URLs, queues, cron, and DNS often need bounded retries for propagation; use `Effect.retry` or `Effect.repeat` with `Schedule`, not manual polling loops. - -## Review checklist - -- The stack path is explicit where the repo has multiple apps or packages. -- Alchemy is used as the deploy/dev control plane unless the repo has an explicit reason not to. -- Stack name, logical IDs, stage names, and physical names are stable and deterministic. -- Provider and state store choices are intentional for the environment. -- Prod deploys pass explicit `--stage` and `--profile`. -- `alchemy dev` is configured for local loops that need real cloud resources, public preview URLs, or webhook testing. -- Runtime resources use bindings rather than scattered string env vars where Alchemy supports bindings. -- Secrets are not committed, echoed in logs, or encoded into stack outputs. -- CI deploys are non-interactive and verify stack outputs or live endpoints. -- Preview deploys have a destroy path. -- Effect implementation follows [effect-typescript](../effect-typescript/SKILL.md); - module layout follows the shared "Readability and module design" section. - -## Contributing back to `alchemy-effect` + + +### Routing judgment + +**Compute target.** Cloudflare Worker is the documented default for that hub; +Durable Objects for globally-unique stateful instances and WebSockets, +Containers for long-lived or arbitrary runtimes, Workflows for durable +checkpointed jobs. On AWS, "choose Lambda unless you have a specific reason not +to"; ECS when the workload is long-running, exceeds 15 minutes, or is already a +container; EKS only when you want Kubernetes itself; EC2 when you need the +machine. Fly, Railway, and Hetzner all bundle an Effect program as a Service, +with no Dockerfile. + +**SQL and databases.** `sql/effect-sql/*` for tagged-template SQL with typed +errors and no ORM; `sql/drizzle/*` for a typed schema and relational queries +(both wrap the same `@effect/sql` drivers); `sql/prisma/*` for Prisma ORM v8 +contracts, Postgres 17+ only. Prisma ORM is separate from the `prisma/` hosting +provider (Prisma Postgres and Prisma Compute). `sql/databases.md` compares +engines, and `sql/_overview.md` maps each runtime plus database plus client +combination to its guide. For the store itself: D1 when SQLite is enough, +Neon for Postgres with copy-on-write branching per preview stage, PlanetScale for +branch-per-PR on Vitess MySQL or managed Postgres, RDS/Aurora when already on +AWS. **Hyperdrive is the pooler, not the database**: Neon, PlanetScale, or RDS +goes behind it. + +**API modality.** `apis/schemaless` is the default for anything internal (Worker +to Worker, Worker to Durable Object); the client type _is_ the class you bound, +with no schema and no runtime validation. Use `apis/effect-rpc` when data crosses +a trust boundary and the consumer is Effect/TypeScript, and `apis/effect-http` +when the consumer is neither and wants real URLs and `curl`. The docs explicitly +discourage the schema'd modalities for internal calls. + +**Pages whose filename does not give them away:** + +- runtime constructors and Worker/Lambda walkthroughs -> + `references/infrastructure-as-effects/runtime.md`; binding contracts and + implementations -> `references/infrastructure-as-effects/binding.md` +- env vars and secrets -> `references/environments/secrets.md` (the + `effect/Config` integration), then `/security/secrets-env.md` +- where `Config` values come from (the Stack's `secrets` field: dotenv, process + env, Doppler, Infisical, and their precedence) -> + `references/environments/secret-providers.md` +- credentials rather than secrets -> `references/environments/profiles.md`. + Profiles control _how alchemy authenticates_; stages control _what is deployed_ +- local dev -> `references/environments/local-development.md`, then + `references//local-development.md` +- CI and PR previews -> `references/environments/ci.md` +- cross-stack wiring -> `references/infrastructure-as-code/references.md` +- renaming without replacing -> `references/infrastructure-as-code/renaming.md` +- taking over existing infrastructure -> `references/cli/adopting-resources.md` +- deploy-time scripts -> `references/infrastructure-as-code/action.md` +- a new cloud or third-party API -> + `references/infrastructure-as-code/custom-provider.md` + +### Per-resource API reference + +The corpus has no page for most individual resources. The generated reference is +not in the repo, but it is served as raw markdown: -If a darkmatter repo needs a missing provider feature, the path is usually a PR upstream rather than a local fork. Two conventions matter when you open that PR: - -- **PR title** uses conventional commits (`feat(aws/s3): add bucket lifecycle rules`, `fix(website): mobile theme metas`). -- **PR description** never starts with a `#` or `##` heading — GitHub already renders the title above it. Use `###` at most, and only when the description genuinely has multiple sections. Lead with a short prose summary or a code snippet that shows the new shape; cut anything that restates what the diff already shows. Never include a "Test plan" or checklist. -- **Outstanding work** belongs in the PR-creation chat, not in the PR body. If something is unfinished or needs manual verification, mark the PR as draft and tell the user what is outstanding. -- **Body delivery**: write the body to a temp file and pass `--body-file` to `gh pr create` / `gh pr edit`. Heredoc/`--body "$(cat …)"` mangles backticks across `gh` versions. - -If you edit tutorial docs under `website/src/content/docs/tutorial/`, one concept gets one `##` heading, one diff snippet, and one prose paragraph. "Two/three things just happened" + a numbered list is the smell that says split the snippet. - -If you edit resource JSDoc, run `bun generate:api-reference` to refresh `website/src/content/docs/providers/{Cloud}/{Resource}.md`. The generated markdown is overwritten on every regeneration — never hand-edit it. - -`reference/provider-implementation.md` has the detailed conventions you need before touching anything under `packages/alchemy/src/` upstream. - -## References +``` +https://alchemy.run/providers///.md +``` -- `reference/upstream-concepts.md` — Alchemy v2 and `alchemy-effect` concepts applied during app/deploy work. -- `reference/examples.md` — `alchemy-run/alchemy-effect/examples` directories mapped to common deploy scenarios. -- `reference/provider-implementation.md` — file system, reconciler doctrine, capability shape, tags, runtime context, test fixtures, JSDoc/docs, and tutorial standards for upstream provider contributions. -- `reference/alchemy-effect/` — pinned git submodule of `alchemy-run/alchemy-effect` for current source, examples, docs, and upstream agent instructions. +for example `/providers/cloudflare/r2/bucket.md`. Fetch that for exact props on a +resource the corpus only mentions in passing. Never invent option keys for one +from the conceptual pages. + +## Traps + +- **Resolving a `Config` only inside `fetch` binds nothing.** Bindings are + discovered by running the init phase, and `fetch` never runs at deploy time. + Resolve `Config` in the outer `Effect.gen`. The docs label this a footgun. +- **Changing a logical ID silently plans a replacement**, deleting the old + physical resource and its data. Use `Alchemy.renamedFrom("OldId")`, listing + former IDs most recent first. +- **v1 to v2 is not state-compatible.** v2 starts from an empty state store, so + the first deploy creates duplicates. Pin `name` to v1's derived + `{app}-{id}-{stage}` and run `deploy --adopt`. Then never run `destroy` in the + v1 project, since its state still points at the same physical resources. + Renames: `entrypoint` -> `main`, `bindings` -> `env`, `await` -> `yield*`. +- **`main: import.meta.url` vs `main: "./src/worker.ts"`** is the Effect vs async + fork. `import.meta.url` means "this file is its own entrypoint". Omitting + `main` deploys an assets-only Worker. +- **Acquiring a disposable at instance scope leaks**, because workerd isolates + are evicted with no teardown hook. Acquire per event. +- **A binding Layer the host cannot satisfy is a compile error**, such as + `Cloudflare.R2.ReadWriteBucketBinding` on an `AWS.Lambda.Function`. +- **`alchemy unsafe nuke` is not scoped to a stack, a stage, or the state + store.** It deletes everything each registered provider's `list()` returns in + the ambient account, including resources Alchemy never created. It is hidden + from `--help` specifically so agents do not discover it. Do not suggest it + casually. `--filter` **spares** on truthy. +- **`retain` protects from Alchemy, not from the provider's cascade**, and must + be set before the deploy that would remove the resource. +- **Moving a resource between local and live is a replacement**, not an update. +- **References fail fast.** Deploy the owner stage first, destroy in reverse. +- **Actions are not idempotent for free**; the body must tolerate retries. +- **R2 refuses to delete a non-empty bucket** unless `forceDestroy: true`. + +## Version notes + +Alchemy v2 is **pre-release** and moving fast. It pins the exact Effect 4 +release candidate named in the corpus stats above (peer range +`>=4.0.0-rc.118 || >=4.0.0`, installed as `effect@rc`); the `effect-ts` skill +documents that same version, so use it for Effect APIs. Since `4.0.0-rc.118`, +unstable modules import without an `unstable` segment (for example +`effect/http/HttpServerResponse` and `effect/http-api/HttpApi`); rewrite any +older `effect/unstable/...` or `effect/httpapi/...` import. Drizzle is pinned to an exact prerelease +build (`drizzle-orm@1.0.0-rc.5-ab785fc`); do not suggest `drizzle-orm@latest`. +Prisma ORM v8 is also a release candidate (`prisma@8.0.0-rc.*`). Better Auth is +on the 1.7 line. Node floor is 22+ (the upstream repo develops on Node 24), Bun +recommended. + +The stage resolution chain is documented inconsistently: prefer +`references/environments/stages.md` over `references/cli/_overview.md`, and treat +the exact stage-name regex as unsettled. + +## Rules + +- The corpus is a point-in-time snapshot of a beta. If it does not cover + something, say so plainly rather than inventing an API. +- Symbol names are exact and case-sensitive. Quote them verbatim; never guess an + export that is not in the file you read. +- Read the specific topic file, not an `INDEX.md`, once routing is clear. + `INDEX.md` files are for disambiguation only. +- Each reference file's header carries its upstream URL. Cite that when pointing + the user at official docs. +- Release notes are deliberately not indexed. For "when did X change", point at + https://alchemy.run/blog. +- Refresh the corpus from upstream: `bun $SKILL_DIR/scripts/build-skill.ts` + (`--check` to verify freshness, `--ref ` to pin). + +## Output + +Lead with the TypeScript that answers the question, then explain the symbols +used, then list the reference file paths consulted. diff --git a/.agents/skills/alchemy/package.json b/.agents/skills/alchemy/package.json new file mode 100644 index 00000000..4dc55ff7 --- /dev/null +++ b/.agents/skills/alchemy/package.json @@ -0,0 +1,14 @@ +{ + "name": "@wyattjoh/alchemy-skill", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "bun scripts/build-skill.ts", + "check": "bun scripts/build-skill.ts --check" + }, + "dependencies": { + "effect": "4.0.0", + "yaml": "^2.9.1" + } +} diff --git a/.agents/skills/alchemy/reference/examples.md b/.agents/skills/alchemy/reference/examples.md deleted file mode 100644 index 8071b05d..00000000 --- a/.agents/skills/alchemy/reference/examples.md +++ /dev/null @@ -1,466 +0,0 @@ -# Upstream Examples Map - -Use the `alchemy-run/alchemy-effect/examples` tree as a pattern library. Prefer linking to upstream and adapting the smallest relevant pattern instead of copying whole examples into a project. - -The inline examples below are small snapshots of the upstream `alchemy.run.ts` entrypoints that are most useful during agent work. Treat them as convenience references, then check the upstream links before making production changes. - -Upstream root: `https://github.com/alchemy-run/alchemy-effect/tree/main/examples` - -## First choices by deploy shape - -| Scenario | Start with | What to inspect | -| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| Basic Cloudflare Worker with R2, D1, Queue, Durable Object, assets, and secrets | `examples/cloudflare-worker` | `alchemy.run.ts`, worker bindings, queue consumer registration, `Cloudflare.InferEnv` | -| Async Cloudflare Worker and Durable Object wiring | `examples/cloudflare-worker-async` | Worker + DO namespace declaration and binding shape | -| Local Cloudflare development with multiple workers and `alchemy dev` | `examples/cloudflare-dev` | Multiple worker outputs, DO binding, dev ports, and feedback loops | -| Next.js / OpenNext app on Cloudflare Workers | Inline `Next.js / OpenNext Worker` example below | `.open-next/worker.js`, `.open-next/assets`, Worker asset handling, `nodejs_compat` flags | -| TanStack app on Cloudflare | `examples/cloudflare-tanstack`, `examples/cloudflare-tanstack-start-solid` | `Cloudflare.Vite`, backend worker binding, `nodejs_compat` | -| Solid/Vue frontend deploys | `examples/cloudflare-solidstart`, `examples/cloudflare-solidjs-ssr`, `examples/cloudflare-vue` | Framework-specific Vite/SSR wiring | -| Cloudflare + Neon + Drizzle | `examples/cloudflare-neon-drizzle` | Database binding and migration shape | -| Cloudflare + PlanetScale + Drizzle | `examples/cloudflare-planetscale-mysql-drizzle`, `examples/cloudflare-planetscale-postgres-drizzle` | Database provider, branch/password resources, Drizzle migration shape | -| Cloudflare secrets | `examples/cloudflare-secrets-store` | Secret storage and Worker access pattern | -| Cloudflare email routing | `examples/cloudflare-email` | Email routing resources and worker integration | -| Git-backed artifacts on Cloudflare | `examples/cloudflare-git-artifacts` | Build/artifact resource flow | -| AWS Lambda function | `examples/aws-lambda` | `AWS.providers()`, `Alchemy.localState()`, Lambda outputs, CloudWatch dashboard/alarm | -| AWS Lambda with HTTP API | `examples/aws-lambda-httpapi` | API Gateway / function URL style HTTP deploy | -| AWS Lambda RPC | `examples/aws-lambda-rpc` | Effect RPC-style runtime wiring | -| AWS REST API | `examples/aws-rest-api` | API Gateway v1 resources | -| AWS static site or Vite | `examples/aws-static-site`, `examples/aws-vite` | Static assets, CDN/site deploy shape | -| AWS EC2/ECS/EKS/RDS | `examples/aws-ec2`, `examples/aws-ecs`, `examples/aws-eks`, `examples/aws-rds` | Long-lived infrastructure resources and provider/region assumptions | -| Monorepo, one stack | `examples/monorepo-single-stack` | Package layout with one deploy unit | -| Monorepo, multiple stacks | `examples/monorepo-multi-stack` | Stack boundaries and deploy ordering | - -## Current example directories - -These directories existed upstream when this reference was refreshed: - -- `aws-ec2` -- `aws-ecs` -- `aws-eks` -- `aws-lambda` -- `aws-lambda-httpapi` -- `aws-lambda-rpc` -- `aws-rds` -- `aws-rest-api` -- `aws-static-site` -- `aws-vite` -- `cloudflare-dev` -- `cloudflare-email` -- `cloudflare-git-artifacts` -- `cloudflare-neon-drizzle` -- `cloudflare-planetscale-mysql-drizzle` -- `cloudflare-planetscale-postgres-drizzle` -- `cloudflare-secrets-store` -- `cloudflare-solidjs-ssr` -- `cloudflare-solidstart` -- `cloudflare-static-site` -- `cloudflare-tanstack` -- `cloudflare-tanstack-start-solid` -- `cloudflare-vue` -- `cloudflare-worker` -- `cloudflare-worker-async` -- `monorepo-multi-stack` -- `monorepo-single-stack` - -Refresh the list before relying on it: - -```bash -curl -s 'https://api.github.com/repos/alchemy-run/alchemy-effect/contents/examples?ref=main' \ - | jq -r '.[] | select(.type == "dir") | .name' \ - | sort -``` - -## Inline examples worth copying - -### Cloudflare Worker - -Source: `https://github.com/alchemy-run/alchemy-effect/blob/main/examples/cloudflare-worker/alchemy.run.ts` - -Use it when you need to wire multiple Cloudflare resources into one Worker and verify binding inference. The detailed bindings live in imported files such as `src/Api.ts`, `src/Bucket.ts`, and `src/AiGateway.ts`. - -```ts -import * as Alchemy from "alchemy"; -import * as Cloudflare from "alchemy/Cloudflare"; -import * as Effect from "effect/Effect"; - -import { Gateway } from "./src/AiGateway.ts"; -import Api from "./src/Api.ts"; -import { Bucket } from "./src/Bucket.ts"; -import SecondaryApiLive, { SecondaryApi } from "./src/SecondaryApi.ts"; -import WorkerTagLive, { WorkerTag } from "./src/WorkerTag.ts"; - -// Demo Action — runs at deploy time when its input (the resolved deployed -// URL) changes. Logs the new URL and returns a tiny manifest used as the -// stack output. Re-deploys with no changes skip the body. -const AnnounceDeploy = Alchemy.Action("AnnounceDeploy", (input: { url: string; bucket: string }) => - Effect.gen(function* () { - yield* Effect.log(`Deployed ${input.url} (bucket: ${input.bucket})`); - return { deployedAt: new Date().toISOString(), url: input.url }; - }), -); - -export default Alchemy.Stack( - "CloudflareWorkerExample", - { - providers: Cloudflare.providers(), - state: Cloudflare.state(), - }, - Effect.gen(function* () { - const api = yield* Api; - const bucket = yield* Bucket; - const gateway = yield* Gateway; - const workerTag = yield* WorkerTag; - // Two Workers binding the same Agent DO triggers the regression where - // a single Container DO namespace appears in multiple bindings on the - // Sandbox ContainerApplication. See SecondaryApi.ts for details. - const secondaryApi = yield* SecondaryApi; - // The Queue consumer is wired automatically by - // `Cloudflare.messages(Queue).subscribe(...)` inside src/Api.ts — - // no explicit `Cloudflare.QueueConsumer(...)` is needed here. - - const announcement = yield* AnnounceDeploy({ - url: api.url.as(), - bucket: bucket.bucketName, - }); - - return { - url: api.url.as(), - bucket: bucket.bucketName, - gatewayId: gateway.gatewayId, - workerTagUrl: workerTag.url.as(), - secondaryApiUrl: secondaryApi.url.as(), - deployedAt: announcement.deployedAt, - }; - }).pipe(Effect.provide(WorkerTagLive), Effect.provide(SecondaryApiLive)), -); -``` - -### Cloudflare Dev - -Source: `https://github.com/alchemy-run/alchemy-effect/blob/main/examples/cloudflare-dev/alchemy.run.ts` - -Use it when the local development path matters as much as deploy: public preview routes, webhook callbacks, hot reload, and debugger-friendly Workers. - -```ts -import * as Alchemy from "alchemy"; -import * as Cloudflare from "alchemy/Cloudflare"; -import * as Effect from "effect/Effect"; -import type { Counter as CounterClass } from "./src/AsyncWorker.ts"; -import EffectWorker from "./src/EffectWorker.ts"; - -export const Counter = Cloudflare.DurableObjectNamespace("Counter", { - className: "Counter", -}); - -export type AsyncWorkerEnv = Cloudflare.InferEnv; - -export const AsyncWorker = Cloudflare.Worker("AsyncWorker", { - main: "./src/AsyncWorker.ts", - bindings: { - Counter, - }, -}); - -export default Alchemy.Stack( - "CloudflareDev", - { - providers: Cloudflare.providers(), - state: Cloudflare.state(), - }, - Effect.gen(function* () { - const asyncWorker = yield* AsyncWorker; - const effectWorker = yield* EffectWorker; - - return { - asyncWorker: asyncWorker.url, - effectWorker: effectWorker.url, - }; - }), -); -``` - -### Cloudflare Vite / TanStack - -Source: `https://github.com/alchemy-run/alchemy-effect/blob/main/examples/cloudflare-tanstack/alchemy.run.ts` - -Use it when a frontend deploy needs to bind directly to backend infrastructure instead of shipping hard-coded env strings. - -```ts -import * as Alchemy from "alchemy"; -import * as Cloudflare from "alchemy/Cloudflare"; -import * as Effect from "effect/Effect"; -import Backend, { Bucket } from "./src/backend.ts"; - -export const Website = Cloudflare.Vite("Website", { - compatibility: { - flags: ["nodejs_compat"], - }, - bindings: { - BUCKET: Bucket, - BACKEND: Backend, - }, -}); - -export type WebsiteEnv = Cloudflare.InferEnv; - -export default Alchemy.Stack( - "CloudflareTanstackExample", - { - providers: Cloudflare.providers(), - state: Cloudflare.state(), - }, - Effect.gen(function* () { - const backend = yield* Backend; - const website = yield* Website; - return { - backendUrl: backend.url.as(), - websiteUrl: website.url.as(), - }; - }), -); -``` - -### Next.js / OpenNext Worker - -Use this for a Next.js app built by OpenNext for Cloudflare Workers. The important shape is a non-bundled Worker entrypoint at `.open-next/worker.js`, assets from `.open-next/assets`, and the compatibility flags Next/OpenNext expects. - -```ts -import * as Alchemy from "alchemy"; -import * as Cloudflare from "alchemy/Cloudflare"; -import * as Effect from "effect/Effect"; - -export default Alchemy.Stack( - "cooperm-web", - { - providers: Cloudflare.providers(), - state: Cloudflare.state(), - }, - Effect.gen(function* () { - const worker = yield* Cloudflare.Worker("cooperm-web", { - main: ".open-next/worker.js", - bundle: false, - assets: { - directory: ".open-next/assets", - config: { - notFoundHandling: "none", - htmlHandling: "auto-trailing-slash", - runWorkerFirst: false, - }, - }, - compatibility: { - date: "2026-03-17", - flags: [ - "nodejs_compat", - "nodejs_compat_populate_process_env", - "global_fetch_strictly_public", - ], - }, - domain: "cm.xyz", - subdomain: { enabled: true }, - }); - - return { - url: worker.url, - }; - }), -); -``` - -### AWS Lambda - -Source: `https://github.com/alchemy-run/alchemy-effect/blob/main/examples/aws-lambda/alchemy.run.ts` - -Use it when an AWS deploy needs function outputs to feed observability resources. - -```ts -import * as Alchemy from "alchemy"; -import * as AWS from "alchemy/AWS"; -import * as Output from "alchemy/Output"; -import * as Effect from "effect/Effect"; -import JobFunction from "./src/JobFunction.ts"; - -// AWS.providers() already provides AWSEnvironment from the SSO profile -// named by $AWS_PROFILE (defaults to "default"). To pin a different -// profile per stage, wrap with `Layer.provide(AWS.makeEnvironment({...}))`. -const aws = AWS.providers(); -const dashboardRegion = process.env.AWS_REGION ?? "us-west-2"; - -export default Alchemy.Stack( - "JobLambda", - { - providers: aws, - state: Alchemy.localState(), - }, - Effect.gen(function* () { - const func = yield* JobFunction; - const dashboard = yield* AWS.CloudWatch.Dashboard("JobDashboard", { - DashboardBody: func.functionName.pipe( - Output.map((functionName) => ({ - widgets: [ - { - type: "metric", - x: 0, - y: 0, - width: 12, - height: 6, - properties: { - title: "Lambda Invocations and Errors", - region: dashboardRegion, - stat: "Sum", - period: 300, - metrics: [ - ["AWS/Lambda", "Invocations", "FunctionName", functionName], - ["AWS/Lambda", "Errors", "FunctionName", functionName], - ], - }, - }, - { - type: "metric", - x: 12, - y: 0, - width: 12, - height: 6, - properties: { - title: "Lambda Duration", - region: dashboardRegion, - stat: "Average", - period: 300, - metrics: [["AWS/Lambda", "Duration", "FunctionName", functionName]], - }, - }, - ], - })), - ), - }); - const alarm = yield* AWS.CloudWatch.Alarm("JobFunctionErrorsAlarm", { - AlarmDescription: "Alerts when the example Lambda function reports errors.", - MetricName: "Errors", - Namespace: "AWS/Lambda", - Statistic: "Sum", - Period: 300, - EvaluationPeriods: 1, - Threshold: 1, - ComparisonOperator: "GreaterThanOrEqualToThreshold", - TreatMissingData: "notBreaching", - Dimensions: [ - { - Name: "FunctionName", - Value: func.functionName, - }, - ], - }); - return { - url: Output.interpolate`${func.functionUrl}?jobId=foo`, - dashboardName: dashboard.dashboardName, - alarmName: alarm.alarmName, - }; - }), -); -``` - -### Monorepo, one stack - -Source: `https://github.com/alchemy-run/alchemy-effect/blob/main/examples/monorepo-single-stack/alchemy.run.ts` - -Use this when backend and frontend should deploy together and frontend build-time env can consume backend outputs from the same stack. - -```ts -import * as Alchemy from "alchemy"; -import * as Cloudflare from "alchemy/Cloudflare"; -import * as Effect from "effect/Effect"; -import { Path } from "effect/Path"; -import Service from "./backend/src/Service.ts"; - -export default Alchemy.Stack( - "Monorepo", - { - providers: Cloudflare.providers(), - state: Cloudflare.state(), - }, - Effect.gen(function* () { - const backend = yield* Service; - const path = yield* Path; - - const website = yield* Cloudflare.Vite("Website", { - rootDir: path.resolve(import.meta.dirname, "frontend"), - env: { - VITE_API_URL: backend.url.as(), - }, - }); - return { - backendUrl: backend.url.as(), - websiteUrl: website.url.as(), - }; - }), -); -``` - -### Monorepo, multiple stacks - -Sources: - -- `https://github.com/alchemy-run/alchemy-effect/blob/main/examples/monorepo-multi-stack/backend/alchemy.run.ts` -- `https://github.com/alchemy-run/alchemy-effect/blob/main/examples/monorepo-multi-stack/frontend/alchemy.run.ts` - -Use this when packages deploy independently and the frontend should reference the backend stack output explicitly. - -```ts -import * as Cloudflare from "alchemy/Cloudflare"; -import * as Effect from "effect/Effect"; -import Service from "./src/Service.ts"; -import { Backend } from "./src/Stack.ts"; - -export default Backend.make( - { - providers: Cloudflare.providers(), - state: Cloudflare.state(), - }, - Effect.gen(function* () { - const api = yield* Service; - return { - url: api.url.as(), - }; - }), -); -``` - -```ts -import * as Alchemy from "alchemy"; -import * as Cloudflare from "alchemy/Cloudflare"; -import { Backend } from "@monorepo-multi-stack/backend"; -import * as Effect from "effect/Effect"; - -export default Alchemy.Stack( - "Frontend", - { - providers: Cloudflare.providers(), - state: Cloudflare.state(), - }, - Effect.gen(function* () { - // reference the prod stage of the backend - const backend = yield* Backend; - - const website = yield* Cloudflare.Vite("Website", { - env: { - VITE_API_URL: backend.url, - }, - }); - - return { - url: website.url.as(), - }; - }), -); -``` - -## Adaptation checklist - -- Preserve the upstream example's provider/state pairing unless the target repo has a stronger convention. -- Rename stack and logical IDs deliberately; do not preserve demo names in production. -- Replace demo secrets and fake values with repo-approved secret plumbing. -- Add stage/profile scripts for every shared environment. -- Add a `dev` script for projects where `alchemy dev` should be the standard local loop. -- Add a smoke check that consumes returned stack outputs. -- For webhook-driven apps, document which output URL should be registered with the external provider during dev or preview testing. -- Keep references as links to upstream examples so the local docs do not drift. -- For Next.js/OpenNext Workers, keep `bundle: false`, asset paths, and compatibility flags aligned with the build output generated by the target project. diff --git a/.agents/skills/alchemy/reference/provider-implementation.md b/.agents/skills/alchemy/reference/provider-implementation.md deleted file mode 100644 index 00aaff93..00000000 --- a/.agents/skills/alchemy/reference/provider-implementation.md +++ /dev/null @@ -1,483 +0,0 @@ -# Provider Implementation Reference - -Use this reference when contributing **upstream to `alchemy-effect`** — adding or editing resources, capabilities, event sources, or test fixtures under `packages/alchemy/src/`. For app-level deploy work, stay in `SKILL.md` and `upstream-concepts.md`. - -The upstream `AGENTS.md` is the canonical source for contributions to that -repository. This file records upstream-specific shapes; it does not prescribe -them for Darkmatter application packages. Re-check the pinned upstream source -before landing a PR. App packages follow the "Readability and module design" -section in the repository's AGENTS.md and the layout in -[the Alchemy skill](../SKILL.md#provider-module-layout). - -## Core vocabulary - -Infrastructure-as-Effects (IaE) extends IaC by expressing infrastructure and runtime as one type-safe Effect program. - -- **Cloud Provider** — AWS, Cloudflare, Stripe, Planetscale, Neon, etc. -- **Service** — a group of Resources, Functions, and Bindings (`S3`, `SQS`, `Lambda`, `Workers`). -- **Resource** — a named entity with Input Properties (desired state) and Output Attributes (current state). May have a Binding Contract. -- **Stable Properties** — properties unchanged by an Update (ID, ARN). -- **Function** (Runtime) — a Resource whose implementation is an `Effect`. Infrastructure dependencies are inferred from `Req`. -- **Output** — a reference to a Resource's Output Attribute (`Bucket.bucketArn`). -- **Stack** — a collection of Resources/Functions/Bindings deployed together. -- **Stack Instance** — a deployed (Stack, Stage) pair. -- **Logical ID** — stable identifier within a Stack across create/update/delete/replace. -- **Instance ID** — stable across create/update/delete, changes on replace; truncated as the Physical Name suffix. -- **Physical Name** — globally unique cloud name; prefer `createPhysicalName`. -- **Replacement** — create-new → repoint dependants → delete-old (or delete-then-create). -- **Dependency Violation / Eventual Consistency** — common retryable failure modes from cloud APIs. - -## Lifecycle operations - -A Resource Provider implements: - -- **Diff** — decide whether new props trigger update, replace, or no-op. Returns Stable Properties unaffected by update. **Almost never use explicit `no-op`** — return `undefined`/`void` to let the engine apply default update logic. Diff is for replacement detection or optimization. -- **Read** — refresh Output Attributes from the cloud. May return `Unowned(attrs)` to signal a foreign resource; the engine refuses adoption unless `--adopt` is set. -- **Pre-Create** — optional stub creation to break circular dependencies (e.g. Lambda Function A ↔ B). -- **Reconcile** — converge the cloud to desired state. See the doctrine below — this is one unified flow for create, update, and adoption. -- **Delete** — must be idempotent. Missing-resource on delete is not an error. - -Errors split into: - -- **Retryable** — Dependency Violations, Eventual Consistency, transient failures. Use `Effect.retry` with `Schedule`. -- **Non-Retryable** — Validation, Authorization. Fail fast. - -## Reconciler doctrine - -`reconcile` receives `output: Attributes | undefined` and `olds: Props | undefined`: - -| `output` | `olds` | Meaning | -| ----------- | ----------- | --------------------------------------- | -| `undefined` | `undefined` | Greenfield — no prior physical resource | -| defined | defined | Routine update — engine-owned | -| defined | `undefined` | Adoption — engine adopted via `read` | - -The reconciler MUST work for all three. **Never branch the body on `output === undefined`** — that is rename-and-branch, not reconciliation. One flow: - -``` -1. Observe — derive identifier; read live cloud state via get/describe -2. Ensure — if missing, call create. Catch AlreadyExists/Conflict as a race - and continue. Wait for active state if applicable. -3. Sync — for each mutable aspect (settings, sub-resources, tags, policy): - - read OBSERVED cloud state (not olds) - - compute desired state from news + bindings - - diff observed against desired - - apply only the delta API call (skip API on no-op) -4. Return — re-read final state if needed; return fresh Attributes -``` - -Invariants: - -- **Observation > assumption.** Cloud state is authoritative. `olds` is at most a hint to skip a no-op API call. -- **Each sync step is independently idempotent.** Crash mid-reconcile → re-run → converge. -- **`output` is a cache** for stable identifiers, never a guarantee the resource still exists. -- **Catch `AlreadyExists`/`NotFoundException`/`ResourceInUseException`** — they're races and eventual consistency, not failures. -- **Tag diff baseline is observed cloud tags**, not `olds.tags` or `output.tags`. - -Existence-only resources (Lambda Permission, EC2 Route, IAM AccessKey) have no sync step — reconciler is observe → if missing, create. - -Reference reconcilers: [S3 Bucket](https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/AWS/S3/Bucket.ts), [SQS Queue](https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/AWS/SQS/Queue.ts), [Kinesis Stream](https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/AWS/Kinesis/Stream.ts), [DynamoDB Table](https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/AWS/DynamoDB/Table.ts), [EC2 Vpc](https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/AWS/EC2/Vpc.ts), [Lambda Function](https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/AWS/Lambda/Function.ts), [Cloudflare Worker](https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/Cloudflare/Workers/Worker.ts). - -## File system conventions - -```sh -packages/alchemy/src/{Cloud}/{Service}/index.ts # re-exports -packages/alchemy/src/{Cloud}/{Service}/{Resource}.ts # resource contract + provider -packages/alchemy/src/{Cloud}/{Service}/{Capability}.ts # Binding.Service + Binding.Policy -packages/alchemy/test/{Cloud}/{Service}/{Resource}.test.ts -website/src/content/docs/providers/{Cloud}/{Resource}.md # auto-generated — DO NOT edit -``` - -Examples: `AWS/S3/Bucket.ts`, `AWS/S3/GetObject.ts`, `AWS/SQS/Queue.ts`, `AWS/SQS/SendMessage.ts`, `AWS/Lambda/Function.ts`, `AWS/DynamoDB/Table.ts`, `AWS/EC2/Vpc.ts`. - -## Resource contract shape - -The `Resource` interface takes four type parameters: `Resource`. The fourth is optional and only present when the resource accepts bindings (Lambda Function, Cloudflare Worker). - -```ts -export interface Stream extends Resource< - "AWS.Kinesis.Stream", - StreamProps, - { - streamName: string; - streamArn: string; - streamStatus: StreamStatus; - } -> {} -export const Stream = Resource("AWS.Kinesis.Stream"); - -export interface Function extends Resource< - "AWS.Lambda.Function", - FunctionProps, - { - functionArn: string; - functionName: string; - functionUrl: string | undefined; - roleName: string; - roleArn: string; - }, - { env?: Record; policyStatements?: PolicyStatement[] } -> {} -``` - -Rules: - -- Wrap inputs in `Input` only when they may reference another resource's Output Attribute (`Input`, `Tags: Record>`). -- **Never** wrap properties that must be statically knowable in `diff` (`name`, `bucketName`, `bucketPrefix`). -- Resource-level JSDoc carries `@section` + `@example` blocks. Every prop and attribute gets field-level JSDoc with `@default` where applicable. - -## Capability shape: Binding.Service + Binding.Policy - -Each capability ships four exports: - -```ts -// 1. Runtime SDK wrapper class -export class PutRecord extends Binding.Service<...>()("AWS.Kinesis.PutRecord") {} - -// 2. Live layer for the runtime — provided on the Function Effect -export const PutRecordLive = Layer.effect(PutRecord, ...); - -// 3. Deploy-time policy class -export class PutRecordPolicy extends Binding.Policy<...>()("AWS.Kinesis.PutRecord") {} - -// 4. Live layer for the policy — provided on the Stack via AWS.providers()() -export const PutRecordPolicyLive = Layer.effect(PutRecordPolicy, ...); -``` - -Then register the Policy in `AWS.providers()()` by adding `*PolicyLive` to `bindings()` in [Providers.ts](https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/AWS/Providers.ts) and re-export from the service `index.ts`. - -`Binding.Policy.attach` calls `ctx.bind({ policyStatements: [...] })` on the target Function, which records binding data on the Stack. The provider's `reconcile` receives resolved binding data via the `bindings` parameter. At runtime, `Binding.Policy` uses `Effect.serviceOption` so it gracefully becomes a no-op. - -Event Sources (`SQS QueueEventSource`, `S3 BucketEventSource`, `DynamoDB streams(table)`) follow the same Binding.Service + Binding.Policy shape, where the policy attach creates/updates the event source mapping. - -When a canonical resource needs mutable event-source configuration with possible circularity, **prefer a resource binding contract over a plain input prop**. DynamoDB Streams is the reference case: `Table` owns the actual stream state; `streams(table)` injects via bindings. - -## RuntimeContext: coloring runtime-only methods - -The inner Effect returned by a `Binding.Service`'s `.bind(resource)` MUST declare `Alchemy.RuntimeContext`: - -```ts -import type { RuntimeContext } from "../../RuntimeContext.ts"; - -export class GetItem extends Binding.Service< - GetItem, - ( - table: T, - ) => Effect.Effect< - (request: GetItemRequest) => Effect.Effect< - DynamoDB.GetItemOutput, - DynamoDB.GetItemError, - RuntimeContext // ← runtime-only - > - > ->()("AWS.DynamoDB.GetItem") {} -``` - -Rules: - -- **Outer Effect** (`bind(resource)` setup) runs at function init. No `RuntimeContext` requirement. -- **Inner Effect** (the SDK invocation) requires `RuntimeContext`. -- Resolve cloud env services (`WorkerEnvironment`, AWS SDK clients) once during Layer construction; close over them. Never leak `WorkerEnvironment` / `Lambda.FunctionEnvironment` onto the runtime callable — it couples downstream services to a specific cloud. -- The Function/Worker runtime satisfies `RuntimeContext` automatically; you don't have to provide it explicitly in the implementation. - -## Region and account access - -Resolve region/account **inside lifecycle operations**, not at Layer construction: - -```ts -reconcile: Effect.fn(function* ({ id, news, output, session }) { - const region = yield* Region; - const accountId = yield* Account; -}); -``` - -This scopes the lookup to the resource, not the provider. - -## Tag handling - -Always apply Alchemy's internal tags so the engine can identify resources it owns: - -```ts -reconcile: Effect.fn(function* ({ id, news, output, session }) { - const internalTags = yield* createInternalTags(id); - const userTags = news.tags ?? {}; - const allTags = { ...userTags, ...internalTags }; -}); -``` - -Never roll your own tag diff. Use `diffTags` from [Tags.ts](https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/src/Tags.ts) and diff against **observed cloud tags** (not `olds.tags` or `output.tags`): - -```ts -const internalTags = yield * createInternalTags(id); -const newTags = { ...news.tags, ...internalTags }; -const oldTags = yield * fetchObservedTags(/* … */); -// Pick the shape that matches the API: -const { removed, upsert } = diffTags(oldTags, newTags); // combined create/update -const { removed, added, updated } = diffTags(oldTags, newTags); // separate calls -const { upsert } = diffTags(oldTags, newTags); // PUT/UPDATE only -``` - -## Effect platform rules (provider code AND tests) - -Never use `async`/`await`, raw `Promise`, `node:fs/promises`, `node:fs`, `node:os`, or `pathe` directly: - -| Don't | Do | -| ------------------------------------------- | ---------------------------------------------------- | -| `import fs from "node:fs/promises"` | `const fs = yield* FileSystem.FileSystem` | -| `await fs.readFile(p, "utf8")` | `yield* fs.readFileString(p)` | -| `await fs.mkdtemp(...)` | `yield* fs.makeTempDirectory({ prefix: ... })` | -| `import path from "pathe"` / `node:path` | `const path = yield* Path.Path` | -| `await fetch(...)` | `yield* HttpClient.HttpClient` + `HttpClientRequest` | -| `Effect.promise(() => listSqlFiles(dir))` | Make `listSqlFiles` return `Effect` and `yield*` it | -| `new Promise((res) => setTimeout(res, ms))` | `yield* Effect.sleep(Duration.millis(ms))` | - -Check upstream rules for required wrappers in an upstream contribution. In -Darkmatter app code, suspend environment reads and side effects in Effect and -classify expected failures at external boundaries. Pure transformations remain -plain TypeScript; `Effect.sync` does not make a CPU calculation interruptible. -An upstream-specific wrapper may look like: - -```ts -const hash = yield * Effect.sync(() => crypto.createHash("sha256").update(input).digest("hex")); -``` - -Never use `Effect.orDie` inside lifecycle operations — it crashes the whole IaC engine. - -## Test fixtures for Effect-native runtimes - -Layout — fixtures live next to the test that owns them, never shared across suites: - -```sh -packages/alchemy/test/{Cloud}/{Service}/{Resource}.test.ts -packages/alchemy/test/{Cloud}/{Service}/fixtures/{worker|workflow|handler}.ts -``` - -Fixture shape — define the Function/Worker with the bindings under test, expose one HTTP route per behavior, default-export: - -```ts -// fixtures/worker.ts -import * as Cloudflare from "@/Cloudflare/index.ts"; -import * as Effect from "effect/Effect"; -import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; -import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; -import { Gateway } from "./gateway.ts"; - -export default class TestWorker extends Cloudflare.Worker()( - "TestWorker", - { main: import.meta.filename }, - Effect.gen(function* () { - const aiGateway = yield* Cloudflare.AiGateway.bind(Gateway); - return { - fetch: Effect.gen(function* () { - const request = yield* HttpServerRequest; - if (request.url.startsWith("/url")) { - const url = yield* aiGateway.getUrl().pipe(Effect.orDie); - return yield* HttpServerResponse.json({ url }); - } - return HttpServerResponse.text("ok"); - }), - }; - }).pipe(Effect.provide(Cloudflare.AiGatewayBindingLive)), -) {} -``` - -Test shape — deploy once with `beforeAll`, drive over HTTP, retry first request: - -```ts -import * as Alchemy from "@/index.ts"; -import * as Cloudflare from "@/Cloudflare"; -import * as Test from "@/Test/Vitest"; -import { expect } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Schedule from "effect/Schedule"; -import * as HttpClient from "effect/unstable/http/HttpClient"; -import TestWorker from "./fixtures/worker.ts"; - -const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ - providers: Cloudflare.providers(), -}); - -const Stack = Alchemy.Stack( - "ServiceTestStack", - { providers: Cloudflare.providers(), state: Cloudflare.state() }, - Effect.gen(function* () { - const worker = yield* TestWorker; - return { url: worker.url.as() }; - }), -); - -const stack = beforeAll(deploy(Stack)); -afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack)); - -test( - "deployed worker exercises the binding", - Effect.gen(function* () { - const { url } = yield* stack; - const client = yield* HttpClient.HttpClient; - const res = yield* client - .get(`${url}/url`) - .pipe(Effect.retry({ schedule: Schedule.exponential("500 millis"), times: 10 })); - expect(res.status).toBe(200); - }), - { timeout: 180_000 }, -); -``` - -Rules: - -- `Test.make({ providers })` provides `test`, `beforeAll`, `afterAll`, `deploy`, `destroy`. -- Always retry the first request — fresh workers.dev URLs and Lambda Function URLs take seconds to serve 200s. -- For POST: `client.post(url)` for empty bodies, or `HttpClient.execute(HttpClientRequest.post(url).pipe(HttpClientRequest.bodyJsonUnsafe(body)))`. -- `NO_DESTROY=1` is a local iteration knob only; never the CI default. -- **Never use `while (Date.now() < deadline)` loops** to poll for async side effects (workflow status, cron fires, queue drains). Use `Effect.repeat` with `Schedule` + `until` + bounded `times: N`: - - ```ts - const value = - yield * - fetchValue.pipe( - Effect.repeat({ - schedule: Schedule.spaced("5 seconds"), - until: (v) => v.ready, - times: 36, - }), - ); - ``` - -- Never use `Date.now()` in physical names — let Alchemy generate the name from app/stage/logical id, or construct a deterministic name unique to the test case. -- Per-binding test layout: one `describe("")` block per binding, all driving the same deployed Function. -- Tests must use `FileSystem.FileSystem` / `Path.Path` for file/path access. - -Reference fixtures: [Cloudflare AiGateway](https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/test/Cloudflare/AiGateway/), [Cloudflare D1Connection](https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/test/Cloudflare/D1/), [Cloudflare Workflow](https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/test/Cloudflare/Workers/), [Cloudflare Cron Trigger](https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/test/Cloudflare/Workers/CronEventSource.test.ts), [Cloudflare Images](https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/test/Cloudflare/Images/), [AWS Lambda DynamoDB bindings](https://github.com/alchemy-run/alchemy-effect/blob/main/packages/alchemy/test/AWS/DynamoDB/). - -## Spec-driven service bring-up - -Use [@processes/AWS.md](https://github.com/alchemy-run/alchemy-effect/blob/main/processes/AWS.md) as the source of truth for bringing a single service from zero to full coverage. The high-level loop: - -1. **Research** the service — list Resources, Identifier Types, Structs, Capabilities, Event Sources. Cross-reference Terraform Provider, Pulumi Provider, and CloudFormation docs. -2. **Document each Resource** — `ResourceName`, Input Properties (Name, Type, Description, Default, Required, Constraints, Replaces: true/false), Output Attributes. -3. **Document each Capability** — name (1:1 with cloud API), constraints, IAM policy mapping, env vars injected into runtime. -4. **Design lifecycle ops** — Diff (stable/conditional/replacement), Read (refresh + adoption path), Pre-Create (only if circular), Reconcile (observe-ensure-sync flow), Delete (idempotent + dependency-violation handling). -5. **Design test cases** — single-step happy paths, multi-step update/replace sequences, multi-resource smoke tests. -6. **Implement** Resource + Provider co-located in `{Resource}.ts`. -7. **Implement Capabilities** as `Binding.Service` + `Binding.Policy` pairs, register Policy in `AWS.providers()()`. -8. **Implement tests** using the fixture pattern above. -9. **Write a smoke test** that combines commonly-paired resources (see the [VPC Smoke Test](https://github.com/alchemy-run/alchemy-effect/blob/main/test/AWS/EC2/Vpc.smoke.test.ts) for an example). -10. **Add JSDoc** at resource and field level, then run `bun generate:api-reference`. - -The audit-driven implementation loop, registration/binding-coverage checks, and Lambda fixture conventions live in `processes/AWS.md` and evolve there — keep this reference high-level. - -## Documentation generation - -**Source of truth: JSDoc on the resource `.ts` file.** The generated markdown under `website/src/content/docs/providers/{Cloud}/{Resource}.md` is overwritten on every regeneration. Never hand-edit it. - -To refresh after editing JSDoc: - -```sh -bun generate:api-reference -``` - -[scripts/generate-api-reference.ts](https://github.com/alchemy-run/alchemy-effect/blob/main/scripts/generate-api-reference.ts) walks `packages/alchemy/src/{Cloud}/{Service}/`, parses TypeScript with `ts-morph`, extracts resource-level summary plus `@section` / `@example` blocks, and writes one markdown file per resource. - -### JSDoc requirements - -Every Prop and Attribute gets JSDoc, with `@default` where it applies: - -```typescript -export interface BucketProps { - /** - * Name of the bucket. If omitted, a unique name will be generated. - * Must be lowercase and between 3-63 characters. - */ - bucketName?: string; - /** - * Whether to delete all objects when the bucket is destroyed. - * @default false - */ - forceDestroy?: boolean; -} -``` - -### `@section` and `@example` on the Resource export - -Examples are critical. Use `@section` to create a heading + ToC entry, then one or more `@example` blocks per section: - -````typescript -/** - * An S3 bucket for storing objects. - * - * @section Creating a Bucket - * @example Basic Bucket - * ```typescript - * const bucket = yield* Bucket("my-bucket", {}); - * ``` - * - * @section Reading Objects - * @example Get Object from Bucket - * ```typescript - * const response = yield* getObject(bucket, { key: "my-key" }); - * ``` - */ -export const Bucket = Resource<...>("AWS.S3.Bucket"); -```` - -Best practices: simplest → most complex, cover all major capabilities (Get/Put/Delete), show realistic patterns (error handling, multi-resource composition), descriptive titles. - -## Tutorial documentation standard - -Tutorials under `website/src/content/docs/tutorial/` are step-by-step and granular: every code snippet introduces exactly **one** new thing, followed by a short prose explanation of just that thing. Each step gets its own `##` heading. - -**Anti-pattern** — one snippet with multiple changes, followed by "Two/three things just happened" + a numbered list. If you find yourself writing that list, split the snippet. - -**Correct shape** — one concept ⇒ one heading ⇒ one diff snippet ⇒ one explanation paragraph (no bullets): - -````md -## Bind the DO to the Worker - -```diff lang="typescript" -+import Counter from "./counter.ts"; - - Effect.gen(function* () { -+ const counters = yield* Counter; - ... - }) -``` - -`yield* Counter` registers the DO with the Worker (binding + class-migration metadata) and hands you the namespace. - -## Call the DO from `fetch` - -```diff lang="typescript" -+import { HttpServerRequest } from "..."; - - fetch: Effect.gen(function* () { -+ const request = yield* HttpServerRequest; -+ ... - }) -``` - -`counters.getByName(name)` returns a typed stub — `increment()` and `get()` round-trip through Cloudflare's RPC machinery. -```` - -Bullet/numbered lists are fine for genuinely list-shaped content (recaps, prerequisites). They are not a substitute for splitting a compound snippet. A single API that internally does several things (`Cloudflare.upgrade()`) doesn't need splitting — describe its behavior in prose. Use `diff lang="typescript"` blocks so each step shows what's added on top of the previous step. - -## PR conventions - -When opening an upstream PR: - -- **Title** uses Conventional Commits (`feat(aws/s3): add bucket lifecycle rules`, `fix(website): mobile theme metas`). -- **Description** never starts with `#` or `##` — GitHub renders the PR title above it. Smallest heading allowed is `###`, and only when the description genuinely has multiple sections worth separating. -- **Content** — minimal prose, prefer code/diff snippets, cut anything restating the diff. No "Test plan", no "Testing", no TODO checklists. If something needs manual verification, mark the PR draft and tell the user in the chat. -- **Body delivery** — write to a temp file and pass `--body-file` to `gh pr create` / `gh pr edit`. Heredoc/`--body "$(cat …)"` mangles backticks across `gh` versions. If `gh pr edit --body-file` silently no-ops on older `gh`, fall back to `gh api -X PATCH repos///pulls/ -F body=@/tmp/pr-body.md`. - -Good example body (prose summary + diff, no headings above it): - -```` -Track which state-store backend each project uses by emitting a `state_store.init` span tagged with `alchemy.state_store.kind`. - -```ts -// every Layer.effect(State, …) site now wraps construction: -makeLocalState().pipe(recordStateStoreInit("local")) -``` - -Dashboard groups projects by kind from these spans (Axiom can't APL-query metric datasets). -```` diff --git a/.agents/skills/alchemy/reference/upstream-concepts.md b/.agents/skills/alchemy/reference/upstream-concepts.md deleted file mode 100644 index 8474f11c..00000000 --- a/.agents/skills/alchemy/reference/upstream-concepts.md +++ /dev/null @@ -1,100 +0,0 @@ -# Upstream Concepts - -This reference distills the Alchemy v2 docs and the `alchemy-run/alchemy-effect` `AGENTS.md` guidance into development and deploy-review rules. Check upstream before applying these notes because the project is still moving quickly. - -## Alchemy v2 app deploy concepts - -- `Alchemy.Stack` is the top-level deployment unit. It groups resources, wires providers, persists state, and returns deploy outputs. -- `alchemy.run.ts` is the CLI convention. Any default-exported stack file can be deployed by passing the path, for example `alchemy deploy stacks/api.ts`. -- Stages isolate deployments. The default is developer-specific; shared environments should be explicit: `--stage staging`, `--stage prod`, or `--stage pr-123`. -- Profiles own credentials. First interactive deploy can configure a default profile; CI and production deploys should pass explicit profiles and avoid prompts. -- State is part of the deploy design. Choose local state only for local/dev examples; use provider-backed or team-approved state for shared environments. -- Stack outputs are the deploy contract for smoke tests and downstream stacks. Return URLs, names, IDs, and dashboards that humans or CI need after deploy. -- Cross-stack references should use typed stack handles instead of stringly-typed lookups when one stack depends on another stack's persisted outputs. - -## Alchemy dev concepts - -- `alchemy dev` uses the same stack model as deploy, but adapts resources for local development. -- Infrastructure still deploys to real cloud providers. R2, D1, KV, queues, databases, and similar resources are not local mocks. -- Supported runtime code runs locally with hot reload. For Cloudflare Workers, Alchemy uses `workerd` locally and a cloud proxy to route requests. -- Dev mode is useful when external systems need to call the app, including webhooks, OAuth callbacks, and partner integrations. -- Resources can adapt based on `ALCHEMY_PHASE=dev`. Keep dev-specific behavior explicit and do not leak it into production deploys. -- Custom dev ports belong in resource config when callback URLs or multiple local services require stable routing. - -## Darkmatter preference - -Alchemy should be the default control plane for new Darkmatter TypeScript/Effect infrastructure: - -- Use Alchemy stages for dev, preview, staging, and prod isolation. -- Use `alchemy dev` before adding one-off tunnels or local emulators. -- Use Alchemy-managed deploy outputs as the source of truth for preview URLs. -- Use provider CLIs for inspection and emergency operations, not as the long-term deploy path unless documented. - -## Infrastructure-as-Effects - -`alchemy-effect` frames deploys as Infrastructure-as-Effects: - -- A cloud provider offers services. -- A service offers resources, functions, and bindings. -- A resource has input properties, output attributes, stable properties, and lifecycle operations. -- A function/runtime is a special resource whose implementation is an `Effect`. -- Runtime dependencies are inferred from Effect requirements and wired through bindings and Layers. - -For application deploys, the practical implication is simple: declare cloud infrastructure and runtime wiring in one type-checked Effect program, but keep domain behavior in runtime services rather than turning `alchemy.run.ts` into application logic. - -## Resource lifecycle model - -When reviewing resource or provider behavior, use the upstream lifecycle vocabulary: - -- `Diff` decides whether changes update, replace, or leave a resource alone. -- `Read` refreshes live cloud state and may detect an existing unowned resource. -- `Pre-Create` exists for special circular cases such as functions needing stubs before final wiring. -- `Reconcile` converges live state to desired state for greenfield create, update, and adoption. -- `Delete` must be idempotent because state persistence can fail after cloud deletion. - -For ordinary deploy configuration, this means: - -- Avoid resource names that fight Alchemy's deterministic naming. -- Expect provider APIs to be eventually consistent. -- Use bounded retries for first requests and async propagation. -- Treat adoption as a deliberate action, not an accidental takeover. - -## Binding model - -Bindings connect resources to runtime functions and workers without hand-writing brittle environment glue. - -- `Binding.Service` is the runtime SDK/service wrapper that application code uses. -- `Binding.Policy` is the deploy-time attachment that grants permissions or injects platform bindings. -- Binding data is collected by the stack and received by providers during reconcile. -- Circular resource relationships should usually be solved through bindings rather than one-off string props. - -In app deploys, prefer provider-supported `bindings` fields and typed helpers over manually copying resource IDs into env vars. - -## Effect runtime rules - -Apply these upstream `AGENTS.md` rules when writing deployable runtime logic or tests. -General Effect service, error, and config patterns are in -[effect-typescript](../../effect-typescript/SKILL.md). - -- Use Effect platform services for files, paths, HTTP, clocks, sleeps, and retries. -- Wrap sync Node APIs in `Effect.sync` or `Effect.try` so they participate in tracing and interruption. -- Do not call raw async I/O inside `Effect.gen`; make helpers return Effects and `yield*` them. -- Use typed errors and `Effect.retry` or `Effect.repeat` with `Schedule` for transient provider and edge propagation failures. -- Do not poll with `while (Date.now() < deadline)` loops. Use bounded schedules and predicates. -- Do not use `Date.now()` to construct physical resource names. Use generated names or deterministic names. - -## Test and smoke deploy guidance - -For Effect-native Workers, Workflows, Lambdas, and similar runtimes: - -- Put fixtures next to the test suite that owns them. -- Deploy the fixture once in `beforeAll`, destroy it in `afterAll`, and drive it over HTTP or the relevant public interface. -- Retry the first request through edge/URL propagation. -- Use `NO_DESTROY=1` only as a local iteration aid and never as a CI default. -- Prefer one deployed smoke fixture that exercises bindings end-to-end over isolated mocks that skip provider wiring. - -## Documentation and examples - -When contributing provider resources upstream, docs are generated from source JSDoc. Edit JSDoc on the resource `.ts` file and regenerate; do not manually edit generated provider markdown. - -For app repos, keep local deploy examples small and executable. Link to upstream examples for full patterns instead of copying large example trees into project docs. diff --git a/.agents/skills/alchemy/references/acme/INDEX.md b/.agents/skills/alchemy/references/acme/INDEX.md new file mode 100644 index 00000000..e30ef4ab --- /dev/null +++ b/.agents/skills/alchemy/references/acme/INDEX.md @@ -0,0 +1,14 @@ +# acme index + +8 pages. Manage TLS certificates independently of your hosting provider, at deploy time or from an application. + +| Page | File | Covers | +| --- | --- | --- | +| ACME | `_overview.md` | Manage TLS certificates independently of your hosting provider, at deploy time or from an application. | +| Certificate authorities | `certificate-authorities.md` | Select a CA, configure external account binding, and understand account identity and key storage. | +| DNS validation | `dns-validation.md` | Configure Cloudflare DNS-01 challenges, wildcard certificates, propagation, and custom solvers. | +| Getting started | `getting-started.md` | Issue a staging TLS certificate with Let's Encrypt and an existing Cloudflare DNS zone. | +| Renewal & revocation | `renewal.md` | Schedule renewal, understand replacement and deletion, and protect certificate and account keys. | +| Runtime issuance | `runtime.md` | Deploy an authenticated Worker that issues and revokes certificates using a bound ACME account. | +| Troubleshooting | `troubleshooting.md` | Diagnose failed DNS challenges, rejected orders, rate limits, and runtime certificate operations. | +| Using certificates | `using-certificates.md` | Upload an ACME certificate to Fly or pass its chain and private key to another TLS consumer. | diff --git a/.agents/skills/alchemy/references/acme/_overview.md b/.agents/skills/alchemy/references/acme/_overview.md new file mode 100644 index 00000000..36f32b7f --- /dev/null +++ b/.agents/skills/alchemy/references/acme/_overview.md @@ -0,0 +1,66 @@ + + +# ACME + +> Manage TLS certificates independently of your hosting provider, at deploy time or from an application. + +ACME (Automatic Certificate Management Environment) is the protocol used by +certificate authorities such as Let's Encrypt and ZeroSSL to issue TLS +certificates. Alchemy manages the account and certificate separately from the +service that terminates TLS. You can issue a certificate once and pass its PEM +chain and private key to Fly or another consumer. + +## Choose when to issue + +| | During deployment | From an application | +| --- | --- | --- | +| API | `ACME.Certificate` | `ACME.IssueCertificate` | +| Account | `ACME.Account` | Bind an existing `ACME.Account` | +| DNS-01 validation | Serializable solver descriptor | Runtime DNS client wrapped as a solver | +| Certificate storage | Stack state | Your application's responsibility | +| Renewal | Evaluated on each deployment | Your application's scheduler | +| Revocation | Optional on resource deletion | Explicit `revoke` call | + +Start with a deploy-time certificate unless your application needs to issue +certificates without redeploying. Both paths support wildcard names and use +DNS-01 validation; HTTP-01 and TLS-ALPN-01 are not implemented. + +## Guides + +- [Getting started](/acme/getting-started) — deploy a Let's Encrypt staging + certificate using an existing Cloudflare zone. +- [Certificate authorities](/acme/certificate-authorities) — choose staging or + production, configure ZeroSSL external account binding, and understand account keys. +- [DNS validation](/acme/dns-validation) — configure Cloudflare, validate wildcard + names, tune propagation, and implement a custom solver. +- [Renewal & revocation](/acme/renewal) — schedule deployments, understand + replacement and deletion, and protect persisted keys. +- [Runtime issuance](/acme/runtime) — deploy an authenticated Worker that issues + and revokes certificates, then integrate your own storage and renewal scheduler. +- [Using certificates](/acme/using-certificates) — upload a certificate to Fly + or pass its outputs to another TLS consumer. +- [Troubleshooting](/acme/troubleshooting) — diagnose DNS failures, CA rejection, + rate limits, and runtime connectivity. + +## Security and lifecycle + +Account and deploy-time certificate private keys are stored in stack state. +`Redacted` masks ordinary logs; it does **not** encrypt your state backend. +Protect the state store, backups, deployment credentials, and any runtime that +receives the account key. + +Renewal needs execution: a certificate resource checks its expiry when you deploy, +not in a background process. Runtime-issued certificates are not tracked as stack +resources. In either mode, monitor expiry and arrange renewal before relying on a +certificate in production. + +## API reference + +The generated references document each property and method: + +- [Account](/providers/acme/account) +- [Certificate](/providers/acme/certificate) +- [IssueCertificate](/providers/acme/issuecertificate) +- [IssueCertificateHttp](/providers/acme/issuecertificatehttp) diff --git a/.agents/skills/alchemy/references/acme/certificate-authorities.md b/.agents/skills/alchemy/references/acme/certificate-authorities.md new file mode 100644 index 00000000..2dc800df --- /dev/null +++ b/.agents/skills/alchemy/references/acme/certificate-authorities.md @@ -0,0 +1,159 @@ + + +# Certificate authorities + +> Select a CA, configure external account binding, and understand account identity and key storage. + +An `ACME.Account` is the identity used to sign orders with one certificate +authority (CA). Certificates reference that account; changing the issuing account +replaces the dependent certificate resource. + +## Choose a directory + +| Preset | Intended use | External account binding | +| --- | --- | --- | +| `ACME.LetsEncryptStaging` | Development; untrusted certificates | No | +| `ACME.LetsEncrypt` | Publicly trusted production certificates | No | +| `ACME.ZeroSSL` | ZeroSSL ACME issuance | Required | +| `ACME.GoogleTrustServicesStaging` | Google Trust Services staging | Required | +| `ACME.GoogleTrustServices` | Google Trust Services production | Required | + +A preset selects a directory URL, not an Alchemy subscription. CA eligibility, +issuance limits, validity periods, and terms still apply. Check the CA's current +requirements before deploying. The supplied Google presets do not imply that a +Google account or external binding credentials are provisioned for you. + +## Start with staging + +Inside the stack from [Getting started](/acme/getting-started): + +```typescript +const account = yield* ACME.Account("Issuer", { + ca: ACME.LetsEncryptStaging, + contact: ["mailto:ops@example.com"], + termsOfServiceAgreed: true, +}); +``` + +Staging exercises real account registration and DNS-01 validation. Its certificate +chain is intentionally untrusted; a browser warning is expected even after a +successful deployment. + +## Switch to production + +```diff lang="typescript" + const account = yield* ACME.Account("Issuer", { +- ca: ACME.LetsEncryptStaging, ++ ca: ACME.LetsEncrypt, +``` + +Changing the directory replaces the account and reissues dependent certificates. +Use a separate production stage and state store when development certificates +must remain independent. Schedule [renewal](/acme/renewal) before deploying a +production certificate to a live service. + +## Configure ZeroSSL external account binding + +External account binding (EAB) associates an ACME account with a CA customer +account. Obtain a matching key ID and HMAC key from ZeroSSL and put them in your +secret configuration as `ZEROSSL_EAB_KID` and `ZEROSSL_EAB_HMAC_KEY`. + +```typescript +import * as ACME from "alchemy/ACME"; +import * as Config from "effect/Config"; + +export const Issuer = ACME.Account("Issuer", { + ca: ACME.ZeroSSL, + eab: { + keyId: Config.String("ZEROSSL_EAB_KID"), + hmacKey: Config.Redacted("ZEROSSL_EAB_HMAC_KEY"), + }, + termsOfServiceAgreed: true, +}); +``` + +The HMAC key is the CA-issued base64url value, not your ZeroSSL REST API key. +Keep the pair together. Yield `Issuer` in the stack or reference it from a +certificate to register it. Google Trust Services uses the same `eab` shape with +credentials obtained from Google. + +## Reuse an existing account resource + +```typescript +const account = yield* Issuer; +const certificate = yield* ACME.Certificate("Api", { + account, + identifiers: ["api.example.com"], + solver: Cloudflare.DNS.AcmeSolver({ + zoneId: Config.String("CLOUDFLARE_ZONE_ID"), + }), +}); +``` + +Referencing the same account resource reuses its persisted signing key and account +URL. EAB is used when registering the account; ordinary signed requests use the +account key. Changing the EAB key ID replaces the account. Changing only the HMAC +value does not force an existing account to register again. + +## Choose an account key algorithm + +```diff lang="typescript" + export const Issuer = ACME.Account("Issuer", { + ca: ACME.ZeroSSL, ++ keyAlgorithm: "RS256", +``` + +The default is `ES256`; `RS256` is also supported. Changing the account algorithm +replaces the account. A certificate's separate `keyAlgorithm` controls the key +used by your TLS server, not the account signing key. + +## Use a custom directory + +```typescript +export const PrivateIssuer = ACME.Account("PrivateIssuer", { + ca: { + directoryUrl: "https://ca.internal.example/acme/acme/directory", + }, + termsOfServiceAgreed: true, +}); +``` + +Use this form for another ACME-compatible CA. It must offer DNS-01 challenges for +the requested identifiers. Network access to the directory and authorization +endpoints must be available wherever issuance runs. + +## Trust a private CA's HTTPS endpoint + +```diff lang="typescript" + ca: { + directoryUrl: "https://ca.internal.example/acme/acme/directory", ++ trustedRoot: Config.String("ACME_CA_ROOT_PEM"), + }, +``` + +`trustedRoot` is a PEM trust anchor for the CA's **API connection**, not the chain +that your application serves. The current HTTP implementation supplies Bun's +`fetch` TLS option; other runtimes may ignore it. Use Bun for this private-root +path, or arrange platform-level trust and verify connectivity in your target +runtime. It does not disable certificate verification. + +## Preserve account identity + +Alchemy generates the account key and stores it in stack state. `AccountProps` +does not currently accept an existing account key or account URL, so importing an +externally created account is not supported by this resource. The lower-level +Distilled SDK can accept explicit credentials, but it does not import them into +Alchemy's resource lifecycle. + +Back up state securely. Recreating a lost state file generates a new key rather +than recovering the old account. A crash after CA registration but before the +first state save can likewise leave an account whose key was not persisted. +`Redacted` protects ordinary logs, not the storage backend. + +## Next steps + +- [DNS validation](/acme/dns-validation) — prove control of the requested names. +- [Runtime issuance](/acme/runtime) — bind this account into an application. +- [Account reference](/providers/acme/account) — all account props and outputs. diff --git a/.agents/skills/alchemy/references/acme/dns-validation.md b/.agents/skills/alchemy/references/acme/dns-validation.md new file mode 100644 index 00000000..d464223e --- /dev/null +++ b/.agents/skills/alchemy/references/acme/dns-validation.md @@ -0,0 +1,209 @@ + + +# DNS validation + +> Configure Cloudflare DNS-01 challenges, wildcard certificates, propagation, and custom solvers. + +DNS-01 proves control of a name by publishing a CA-derived TXT value under +`_acme-challenge`. The CA reads that value before issuing the certificate. +Alchemy implements DNS-01 only; your application does not need a public HTTP +challenge endpoint. + +## Reference a Cloudflare zone + +Inside the stack from [Getting started](/acme/getting-started): + +```typescript +const certificate = yield* ACME.Certificate("Site", { + account, + identifiers: ["example.com"], + solver: Cloudflare.DNS.AcmeSolver({ + zoneId: Config.String("CLOUDFLARE_ZONE_ID"), + }), +}); +``` + +`AcmeSolver` returns serializable data containing the solver type and zone ID. +Include both `ACME.providers()` and `Cloudflare.providers()` in the stack. The +Cloudflare credentials must allow listing, creating, and deleting TXT records in +that zone. The domain must already be delegated to that zone's nameservers. + +## Use a managed zone instead + +If your stack already owns a Cloudflare `Zone` resource, pass the resolved zone: + +```diff lang="typescript" ++const zone = yield* Zone; + const certificate = yield* ACME.Certificate("Site", { + account, + identifiers: ["example.com"], +- solver: Cloudflare.DNS.AcmeSolver({ +- zoneId: Config.String("CLOUDFLARE_ZONE_ID"), +- }), ++ solver: Cloudflare.DNS.AcmeSolver(zone), + }); +``` + +The zone's output supplies the ID and establishes the resource dependency. Use +the ID form when you only want to manage challenge records, not the zone's +lifecycle or settings. + +## Add a wildcard + +```diff lang="typescript" +- identifiers: ["example.com"], ++ identifiers: ["example.com", "*.example.com"], +``` + +`*.example.com` covers one label such as `www.example.com`, but not the apex +`example.com` or `a.b.example.com`. Include the apex separately when needed. Both +names validate at `_acme-challenge.example.com`; Alchemy processes the +authorizations sequentially and removes each challenge value afterward. + +## Understand record cleanup + +The deploy-time Cloudflare solver creates TXT records with a 60-second TTL. It +matches both record name and value during cleanup, preserving unrelated values +at the same name. Once `present` succeeds, cleanup runs when validation succeeds, +fails, or is interrupted through Effect's normal finalization. + +A process kill or a failure after the DNS API accepted a write can prevent +cleanup. Inspect any leftover value before removing it: another order may be +using the same record name. Avoid concurrent issuers for the same names where +possible. + +## Adjust the propagation budget + +```diff lang="typescript" + const certificate = yield* ACME.Certificate("Site", { + account, + identifiers: ["example.com", "*.example.com"], + solver: Cloudflare.DNS.AcmeSolver(zone), ++ propagation: { ++ delay: "60 seconds", ++ timeout: "90 seconds", ++ interval: "3 seconds", ++ }, + }); +``` + +These are the deploy-time Cloudflare defaults. The timeout covers the lookup and +post-visibility delay together. Lookup attempts are bounded, so increasing the +timeout alone does not create unlimited polling. Keep enough budget for the +post-visibility delay; cached previous TXT values can cause a secondary CA +validator to reject a record even after one resolver sees it. + +## Choose fallback resolvers + +```diff lang="typescript" + propagation: { + delay: "60 seconds", + timeout: "90 seconds", + interval: "3 seconds", ++ resolvers: [ ++ "https://cloudflare-dns.com/dns-query", ++ "https://dns.google/resolve", ++ ], + }, +``` + +Where Node DNS APIs are available, the deploy-time check prefers authoritative +nameservers. Otherwise it polls DNS-over-HTTPS JSON endpoints and requires every +configured resolver to return the expected TXT value. These endpoints are the +default fallback resolvers; private DNS needs a solver-specific visibility check. + +## Select the runtime solver + +Inside a Worker's initialization Effect: + +```typescript +const dns = yield* Cloudflare.DNS.WriteDns(Zone); +const solver = Cloudflare.DNS.acmeDnsSolver(dns); +``` + +The runtime helper is **`acmeDnsSolver`**, while the deploy-time helper is +**`AcmeSolver`**. Provide `Cloudflare.DNS.WriteDnsHttp` on the host Effect. Its +client uses a zone-scoped token created at deployment, rather than exposing the +deployer's entire credential set to the application. + +## Account for runtime propagation + +```typescript +const issued = yield* issuer.issue({ + identifiers: ["api.example.com"], + solver, + propagation: { delay: "60 seconds" }, +}); +``` + +The Cloudflare runtime solver uses a fixed delay (60 seconds by default), rather +than authoritative or public resolver polling. For this solver, `resolvers`, +`interval`, and `timeout` do not add a DNS visibility check. An issuance request +can take longer than a minute; [Runtime issuance](/acme/runtime) explains the +hosting and scheduling implications. + +## Implement a custom runtime solver + +A custom solver adapts an existing DNS client's idempotent operations: + +```typescript +import type * as ACME from "alchemy/ACME"; +import type * as Effect from "effect/Effect"; + +export const makeSolver = (dns: { + ensureTxt: ( + record: ACME.DnsChallengeRecord, + ) => Effect.Effect; + removeTxt: ( + record: ACME.DnsChallengeRecord, + ) => Effect.Effect; +}): ACME.DnsSolver => ({ + present: (record) => dns.ensureTxt(record), + cleanup: (record) => dns.removeTxt(record), +}); +``` + +Implement `ensureTxt` and `removeTxt` with your provider's DNS API, mapping its +failures to `ACME.DnsSolverError`. Ensure must tolerate the exact value already +existing; removal must tolerate it already being absent. Match the requested +value, not every TXT record at that name. The default propagation check applies +unless you supply the solver's optional `propagated` method. + +## Register a deploy-time solver + +Deploy-time props need a serializable descriptor, not a client with closures. +For an adapter whose client has no remaining Effect requirements, register a +factory with this shape: + +```typescript +import * as ACME from "alchemy/ACME"; +import type * as Effect from "effect/Effect"; + +export const registerExampleDns = ( + make: ( + descriptor: ACME.DnsSolverDescriptor, + ) => Effect.Effect, +) => ACME.dnsSolverLayer("Example.DNS", make); +``` + +Validate the descriptor's fields inside `make`, construct the client, and merge +the returned layer into your stack's providers. Certificate props then use +`solver: { type: "Example.DNS", ...providerData }`. A service-backed factory can +instead provide its required credential and HTTP layers to `dnsSolverLayer`; +the registration captures that context for the solver. Cloudflare's implementation +in `Cloudflare/DNS/AcmeDnsSolver.ts` is the built-in reference. + +## Delegated challenges + +The Cloudflare helper writes directly to the specified zone at the calculated +challenge name. It does not automatically follow CNAME or NS delegations to +another challenge zone. For delegated validation, implement a solver that +publishes to the delegated target and verifies its visibility. Simply changing +`zoneId` is not a delegation resolver. + +## Next steps + +- [Troubleshooting](/acme/troubleshooting) — diagnose propagation and authorization failures. +- [Runtime issuance](/acme/runtime) — use a solver in a complete Worker. diff --git a/.agents/skills/alchemy/references/acme/getting-started.md b/.agents/skills/alchemy/references/acme/getting-started.md new file mode 100644 index 00000000..9d8219aa --- /dev/null +++ b/.agents/skills/alchemy/references/acme/getting-started.md @@ -0,0 +1,194 @@ + + +# Getting started + +> Issue a staging TLS certificate with Let's Encrypt and an existing Cloudflare DNS zone. + +This guide issues a certificate for a domain you control. It uses Let's Encrypt +staging, so you can validate the deployment without consuming production issuance +limits. The resulting certificate is deliberately **not trusted by browsers**. + +## Prepare the project + +Install Alchemy and the matching Effect version using [Cloudflare setup](/cloudflare/setup). +You need an active Cloudflare DNS zone, its zone ID, and credentials that can read, +create, and delete DNS records in that zone. The domain's authoritative nameservers +must already point to Cloudflare. + +Use a domain you own wherever this guide says `example.com`. Set +`CLOUDFLARE_ZONE_ID` in your project's `.env` to the zone ID from its Cloudflare +dashboard. This example references the existing zone by ID; it does not create, +adopt, modify the settings of, or delete the zone itself. + +## Create the stack + +Create `alchemy.run.ts`: + +```typescript +import * as Alchemy from "alchemy"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export default Alchemy.Stack( + "Certificates", + { providers: Layer.empty, state: Alchemy.localState() }, + Effect.gen(function* () { + return {}; + }), +); +``` + +The stack groups the certificate resources and persists their state locally. +Treat that state as secret material and keep it out of source control. For a +shared deployment runner, use a protected [remote state store](/state-store). + +## Register the providers + +```diff lang="typescript" ++import * as ACME from "alchemy/ACME"; ++import * as Cloudflare from "alchemy/Cloudflare"; + + export default Alchemy.Stack( + "Certificates", +- { providers: Layer.empty, state: Alchemy.localState() }, ++ { ++ providers: Layer.mergeAll(ACME.providers(), Cloudflare.providers()), ++ state: Alchemy.localState(), ++ }, +``` + +`ACME.providers()` manages accounts and certificates. `Cloudflare.providers()` +registers the Cloudflare DNS solver used to prove control of the domain. + +## Connect Cloudflare + +```sh +alchemy profile edit --add Cloudflare +``` + +Select the Cloudflare account containing your zone. You can use OAuth or a +suitably scoped API token; [Cloudflare setup](/cloudflare/setup) also documents +non-interactive environment credentials for CI. + +## Create an ACME account + +Inside the stack's `Effect.gen`, add the account before `return`: + +```diff lang="typescript" + Effect.gen(function* () { ++ const account = yield* ACME.Account("Issuer", { ++ ca: ACME.LetsEncryptStaging, ++ contact: ["mailto:ops@example.com"], ++ termsOfServiceAgreed: true, ++ }); + return {}; + }), +``` + +Replace the contact address with yours and review the CA's terms before agreeing. +The account gets its own signing key, which Alchemy persists in stack state and +reuses on subsequent deployments. + +## Request the certificate + +```diff lang="typescript" ++import * as Config from "effect/Config"; + + const account = yield* ACME.Account("Issuer", { + ca: ACME.LetsEncryptStaging, + contact: ["mailto:ops@example.com"], + termsOfServiceAgreed: true, + }); ++ const certificate = yield* ACME.Certificate("Site", { ++ account, ++ identifiers: ["example.com"], ++ solver: Cloudflare.DNS.AcmeSolver({ ++ zoneId: Config.String("CLOUDFLARE_ZONE_ID"), ++ }), ++ }); +``` + +The solver publishes a temporary `_acme-challenge.example.com` TXT record. +It waits for propagation before asking the CA to validate it, then removes that +challenge record. Existing unrelated TXT values are preserved. + +## Return certificate metadata + +```diff lang="typescript" +- return {}; ++ return { ++ serial: certificate.serial, ++ expires: certificate.notAfter, ++ }; +``` + +Return metadata rather than private keys. The PEM chain and redacted key remain +available as resource outputs for a [TLS consumer](/acme/using-certificates). + +## Deploy to a development stage + +```sh +alchemy deploy --stage dev +``` + +Allow time for DNS validation: Cloudflare's solver includes a 60-second wait to +let cached challenge values expire. A successful deployment returns the serial +number and expiry. It does not configure a website or an HTTPS listener. + +## Review the complete file + +The steps above produce this `alchemy.run.ts`: + +```typescript +import * as Alchemy from "alchemy"; +import * as ACME from "alchemy/ACME"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export default Alchemy.Stack( + "Certificates", + { + providers: Layer.mergeAll(ACME.providers(), Cloudflare.providers()), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const account = yield* ACME.Account("Issuer", { + ca: ACME.LetsEncryptStaging, + contact: ["mailto:ops@example.com"], + termsOfServiceAgreed: true, + }); + const certificate = yield* ACME.Certificate("Site", { + account, + identifiers: ["example.com"], + solver: Cloudflare.DNS.AcmeSolver({ + zoneId: Config.String("CLOUDFLARE_ZONE_ID"), + }), + }); + return { + serial: certificate.serial, + expires: certificate.notAfter, + }; + }), +); +``` + +## Clean up the development stack + +```sh +alchemy destroy --stage dev +``` + +Destruction deactivates the ACME account and removes the stack's certificate +state. By default it does not revoke an issued certificate. The existing DNS +zone is not a managed resource in this example and remains intact. Read +[Renewal & revocation](/acme/renewal) before choosing a production deletion policy. + +## Next steps + +- [Certificate authorities](/acme/certificate-authorities) — switch to a trusted production CA. +- [DNS validation](/acme/dns-validation) — add a wildcard or customize propagation. +- [Using certificates](/acme/using-certificates) — attach the chain and key to a TLS consumer. diff --git a/.agents/skills/alchemy/references/acme/renewal.md b/.agents/skills/alchemy/references/acme/renewal.md new file mode 100644 index 00000000..1b5eca19 --- /dev/null +++ b/.agents/skills/alchemy/references/acme/renewal.md @@ -0,0 +1,138 @@ + + +# Renewal & revocation + +> Schedule renewal, understand replacement and deletion, and protect certificate and account keys. + +An `ACME.Certificate` is checked when Alchemy plans a deployment. It has no +background timer. Keeping the stack deployed is not enough to keep its +certificates renewed. + +## Choose a renewal window + +In the certificate from [Getting started](/acme/getting-started): + +```diff lang="typescript" + const certificate = yield* ACME.Certificate("Site", { + account, + identifiers: ["example.com"], + solver: Cloudflare.DNS.AcmeSolver({ + zoneId: Config.String("CLOUDFLARE_ZONE_ID"), + }), ++ renewBefore: "30 days", + }); +``` + +Thirty days is the default. When less than this remains before `notAfter`, a +deployment issues a new certificate with a fresh private key and updates the +resource outputs. Choose a window shorter than the CA's certificate lifetime; +a longer window can cause issuance on every deployment. + +## Schedule deployments + +```sh +alchemy deploy --stage prod --profile prod --yes +``` + +Run the same deployment regularly from your existing scheduler or CI runner. +Daily is a reasonable starting point for a 30-day renewal window. The runner +must use the same stack name, stage, protected state backend, source revision, +and necessary credentials as the production deployment. `--yes` approves the +plan, so review source changes separately and serialize deployments to that +stack. See [CI setup](/environments/ci) for credential configuration. + +## Monitor expiry independently + +Return `certificate.notAfter` from the stack and feed it into your monitoring. +Also inspect the certificate actually served by your TLS endpoint: updating +Alchemy's state does not prove that an external service loaded the replacement. +Alert early enough to investigate DNS failures, rejected orders, or unavailable +credentials before the old certificate expires. + +## Understand which changes issue again + +| Change | Result | +| --- | --- | +| Certificate is inside `renewBefore` | Update: issue a new certificate and key | +| Identifier set changes | Update: issue again | +| Certificate `keyAlgorithm` changes | Update: issue again | +| `preferredChain` changes | Update: issue again | +| Issuing account URL changes | Replace the certificate resource | +| Solver or propagation settings alone change | Keep the valid certificate; use new settings on later issuance | +| `revokeOnDelete` changes | Update deletion policy without issuing again | + +An ordinary renewal updates the stored certificate; it does not automatically +revoke the previous one. Retain any old material securely if your operational +process needs explicit revocation after cutover. + +## Revoke on resource deletion + +```diff lang="typescript" + const certificate = yield* ACME.Certificate("Site", { + account, + identifiers: ["example.com"], + solver: Cloudflare.DNS.AcmeSolver({ + zoneId: Config.String("CLOUDFLARE_ZONE_ID"), + }), ++ revokeOnDelete: true, + }); +``` + +This opts into CA revocation when that certificate resource is deleted, including +deletion of an old resource generation during replacement. It does not revoke +old certificates on every in-place renewal. Revocation uses the certificate's +own private key, so deleting an old certificate can still work after its account +has been deactivated. + +## Destroy a stack deliberately + +```sh +alchemy destroy --stage dev +``` + +With the default `revokeOnDelete: false`, deletion forgets certificate state but +leaves the certificate valid at the CA. Account deletion deactivates the ACME +account; deactivation is distinct from revoking its issued certificates. Removing +a certificate from a TLS consumer is another separate operation. + +:::caution +Save any material needed for later revocation before destroying state. Once the +key is gone, you cannot rely on this stack to revoke the certificate. If the +certificate is still serving traffic, replace it at the consumer before revoking +it. +::: + +## Protect account and certificate keys + +The account's `privateKey` is a redacted private JWK used for ACME requests. The +certificate's `privateKey` is a redacted PKCS#8 PEM used by the TLS server. They +are separate keys with separate purposes. + +Restrict state access to trusted deployment identities, use encryption at rest +where your backend supports it, protect backups, and exclude local state from +source control. `Redacted` is a logging safeguard, not encryption. Avoid returning +keys as top-level stack outputs or unwrapping them for diagnostic logging. + +The certificate resource's read operation returns persisted attributes; it does +not query the CA for out-of-band revocation. Expiry checks alone are therefore +not a certificate-status monitoring system. + +## Renew runtime-issued certificates + +For certificates issued through `IssueCertificate`, your application must store +the chain, key, expiry, and enough account/domain metadata to manage the result. +Schedule a new `issue` call before expiry, install the new material at the +consumer, verify it is serving, and only then retire the previous certificate. + +Serialize issuance per identifier set, persist jobs durably, and honor CA retry +hints. An in-memory Worker variable is not durable storage or a renewal schedule. +Destroying the account stack does not discover or clean up runtime-issued +certificates for you. + +## Next steps + +- [Using certificates](/acme/using-certificates) — connect renewal outputs to a TLS consumer. +- [Runtime issuance](/acme/runtime) — issue and revoke through an application binding. +- [Troubleshooting](/acme/troubleshooting) — investigate a failed renewal. diff --git a/.agents/skills/alchemy/references/acme/runtime.md b/.agents/skills/alchemy/references/acme/runtime.md new file mode 100644 index 00000000..8be2496b --- /dev/null +++ b/.agents/skills/alchemy/references/acme/runtime.md @@ -0,0 +1,316 @@ + + +# Runtime issuance + +> Deploy an authenticated Worker that issues and revokes certificates using a bound ACME account. + +`ACME.IssueCertificate` lets an application issue certificates without deploying +a new `ACME.Certificate` resource. The account is still managed by your stack, +but the application owns the issued certificates' storage, installation, renewal, +and revocation. + +This example deploys an authenticated administrative Worker. `POST /issue` +returns a certificate for one fixed hostname; `POST /revoke` accepts a leaf PEM +and revokes it using the bound account. It is a starting point for an internal +issuer, not a public self-service certificate API. + +## Prepare credentials + +Complete [Cloudflare setup](/cloudflare/setup) and obtain [ZeroSSL EAB credentials](/acme/certificate-authorities#configure-zerossl-external-account-binding). +Set `ZEROSSL_EAB_KID`, `ZEROSSL_EAB_HMAC_KEY`, and a long, randomly generated +`ISSUER_ADMIN_TOKEN` in your deployment's secret configuration. Replace +`example.com` and `api.example.com` below with your zone and hostname. + +The deployment identity also needs permission to create the zone-scoped API +token used by `WriteDnsHttp`. It is not enough to supply a token that can only +edit DNS records. See the [DNS write binding](/providers/cloudflare/dns/writedns). + +:::note[CA reachability] +The deployed-Worker integration test uses ZeroSSL. Let's Encrypt returned TLS +connection errors from deployed Cloudflare Workers during testing, although it +worked for deploy-time and local-workerd issuance. Verify the CA from the actual +hosting environment. ZeroSSL issuance uses its production CA and consumes its +issuance allowance. +::: + +## Define the account + +Create `resources.ts`: + +```typescript +import * as ACME from "alchemy/ACME"; +import * as Config from "effect/Config"; + +export const Issuer = ACME.Account("Issuer", { + ca: ACME.ZeroSSL, + eab: { + keyId: Config.String("ZEROSSL_EAB_KID"), + hmacKey: Config.Redacted("ZEROSSL_EAB_HMAC_KEY"), + }, + termsOfServiceAgreed: true, +}); +``` + +The Worker will bind this account's directory URL, account URL, and redacted +private key. Treat access to the Worker and its configuration as access to the +account. + +## Reference the runtime DNS zone + +Add a zone resource to `resources.ts`: + +```typescript +import { adopt } from "alchemy/AdoptPolicy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import { retain } from "alchemy/RemovalPolicy"; + +export const Zone = Cloudflare.Zone.Zone("DnsZone", { + name: "example.com", +}).pipe(adopt(), retain()); +``` + +Unlike the ID-only deploy-time solver, `WriteDns` binds a zone resource. This +example adopts your existing zone and retains it when the stack is destroyed. +Adoption allows Alchemy to manage the zone's settings, so inspect the plan and +match any non-default settings you need to preserve. If your application already +owns a zone resource, import it instead of declaring another one. + +## Bind the issuer + +In the Worker's initialization Effect, bind the account: + +```typescript +const issuer = yield* ACME.IssueCertificate(Issuer); +``` + +Provide `ACME.IssueCertificateHttp` on that host Effect. The client methods run +inside a request and require Alchemy's runtime context; they are not deploy-time +operations. + +## Bind DNS access + +```typescript +const dns = yield* Cloudflare.DNS.WriteDns(Zone); +``` + +Provide `Cloudflare.DNS.WriteDnsHttp` on the same host Effect. It creates a DNS +write token scoped to `Zone` at deployment. The complete Worker below includes +both implementation layers. + +## Authorize the request + +```typescript +if (request.headers.authorization !== `Bearer ${Redacted.value(adminToken)}`) { + return HttpServerResponse.text("Unauthorized", { status: 401 }); +} +``` + +Read `adminToken` from `Config.Redacted("ISSUER_ADMIN_TOKEN")` during initialization. +Authentication runs before issuance or revocation. A production multi-tenant +issuer also needs per-tenant domain authorization, quotas, and durable job +serialization; possession of a DNS token alone is not tenant authorization. + +## Issue one fixed hostname + +```typescript +const issued = yield* issuer.issue({ + identifiers: ["api.example.com"], + solver: Cloudflare.DNS.acmeDnsSolver(dns), +}); +``` + +Keep the hostname fixed until you have a domain-authorization model. Constructing +a solver per call isolates its cleanup bookkeeping. The result includes `chain`, +`certificate`, `privateKey`, `notAfter`, and order metadata. + +## Revoke a leaf certificate + +```typescript +yield* issuer.revoke({ certificate: yield* request.text }); +``` + +The account signs the revocation request. Alternatively, pass the certificate's +own `privateKey` to sign with that key. An already-revoked response counts as +success. Revocation does not remove a certificate installed on Fly or another +TLS consumer. + +## Assemble the Worker + +Create `issuer-worker.ts`. This complete file combines the bindings, authorization, +and two operations above: + +```typescript +import * as ACME from "alchemy/ACME"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Config from "effect/Config"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import { HttpServerRequest } from "effect/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; +import { Issuer, Zone } from "./resources.ts"; + +export default class IssuerWorker extends Cloudflare.Worker()( + "IssuerWorker", + { main: import.meta.url }, + Effect.gen(function* () { + const issuer = yield* ACME.IssueCertificate(Issuer); + const dns = yield* Cloudflare.DNS.WriteDns(Zone); + const adminToken = yield* Config.Redacted("ISSUER_ADMIN_TOKEN"); + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + if (request.headers.authorization !== `Bearer ${Redacted.value(adminToken)}`) { + return HttpServerResponse.text("Unauthorized", { status: 401 }); + } + if (request.method !== "POST") { + return HttpServerResponse.text("Use POST", { + status: 405, + headers: { allow: "POST" }, + }); + } + const pathname = new URL(request.url, "https://issuer.invalid").pathname; + if (pathname === "/issue") { + const issued = yield* issuer.issue({ + identifiers: ["api.example.com"], + solver: Cloudflare.DNS.acmeDnsSolver(dns), + }); + return yield* HttpServerResponse.json( + { + certificate: issued.certificate, + chain: issued.chain, + privateKey: Redacted.value(issued.privateKey), + notAfter: issued.notAfter, + serial: issued.serial, + }, + { headers: { "cache-control": "no-store" } }, + ); + } + if (pathname === "/revoke") { + const certificate = yield* request.text; + if (!certificate.startsWith("-----BEGIN CERTIFICATE-----")) { + return HttpServerResponse.text("Expected a leaf PEM", { status: 400 }); + } + yield* issuer.revoke({ certificate }); + return HttpServerResponse.text("Revoked"); + } + return HttpServerResponse.text("Not found", { status: 404 }); + }).pipe( + Effect.catchTag("AcmeRateLimited", (error) => + HttpServerResponse.json( + { error: "Certificate authority rate limit" }, + { + status: 429, + headers: error.retryAfter === undefined + ? {} + : { + "retry-after": String(Math.ceil( + Duration.toMillis(error.retryAfter) / 1000, + )), + }, + }, + ), + ), + Effect.catchCause(() => + Effect.succeed( + HttpServerResponse.text("Certificate operation failed", { status: 502 }), + ), + ), + ), + }; + }).pipe( + Effect.provide(ACME.IssueCertificateHttp), + Effect.provide(Cloudflare.DNS.WriteDnsHttp), + ), +) {} +``` + +The response deliberately unwraps the **certificate** private key for the +administrative caller, over HTTPS, with `Cache-Control: no-store`. It never +returns the **account** key. Do not log response bodies or publish this endpoint +without authentication. A production issuer should generally write directly to +its protected certificate store or TLS consumer instead of exporting keys over +HTTP. Replace the generic failure response with sanitized internal diagnostics +when integrating it into your application. + +## Deploy the Worker + +Create `alchemy.run.ts`: + +```typescript +import * as Alchemy from "alchemy"; +import * as ACME from "alchemy/ACME"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import IssuerWorker from "./issuer-worker.ts"; + +export default Alchemy.Stack( + "RuntimeCertificates", + { + providers: Layer.mergeAll(ACME.providers(), Cloudflare.providers()), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const worker = yield* IssuerWorker; + return { url: worker.url }; + }), +); +``` + +Run `alchemy deploy --stage dev` and use the returned HTTPS URL. Protect the +local state, or switch to your protected remote state backend before sharing +this deployment with a CI runner. + +## Call the issuer + +Set `ISSUER_URL` to the deployment output and `ISSUER_ADMIN_TOKEN` in your shell +without committing or sharing its value: + +```sh +umask 077 +curl --fail-with-body --max-time 180 \ + -X POST "$ISSUER_URL/issue" \ + -H "Authorization: Bearer $ISSUER_ADMIN_TOKEN" \ + -o issued.json +``` + +`issued.json` contains a private key; the restrictive umask keeps newly created +files private to your user. Store the result securely rather than printing it. +Every successful call issues again: this endpoint has no cache or idempotency +key. A client timeout can leave issuance completed without a delivered result, +so avoid blind retries. + +## Revoke the test certificate + +```sh +jq -r '.certificate' issued.json > leaf.pem +curl --fail-with-body --max-time 60 \ + -X POST "$ISSUER_URL/revoke" \ + -H "Authorization: Bearer $ISSUER_ADMIN_TOKEN" \ + -H "Content-Type: application/pem-certificate-chain" \ + --data-binary @leaf.pem +``` + +Revoke only after removing or replacing the certificate at any consumer using +it. Dispose of local key material according to your secret-handling policy. +Destroying the Worker stack does not revoke this runtime-issued certificate. + +## Add storage and renewal before production + +Persist the issued material and expiry in an access-controlled store, and serialize +issuance for each identifier set. Schedule a new issuance before `notAfter`, install +the replacement, and verify the live TLS endpoint before retiring the old one. +Neither the Worker binding nor an in-memory variable provides those guarantees. + +DNS propagation alone waits 60 seconds by default. Ensure that your hosting plan, +client timeout, and request lifetime accommodate the full flow. For long-running +production work, use a durable job runner whose lifetime is independent of the +HTTP client, rather than starting untracked work after returning a response. + +The SDK retries individual protocol requests. Catch `AcmeRateLimited` to preserve +the CA's `retryAfter` hint, as above, and reschedule durably rather than repeatedly +retrying the entire issuance flow. See [Troubleshooting](/acme/troubleshooting). diff --git a/.agents/skills/alchemy/references/acme/troubleshooting.md b/.agents/skills/alchemy/references/acme/troubleshooting.md new file mode 100644 index 00000000..6b8f1cef --- /dev/null +++ b/.agents/skills/alchemy/references/acme/troubleshooting.md @@ -0,0 +1,132 @@ + + +# Troubleshooting + +> Diagnose failed DNS challenges, rejected orders, rate limits, and runtime certificate operations. + +Start with the error tag and the affected identifier. Verify your DNS path with +Let's Encrypt staging before repeatedly placing production orders. Preserve +sanitized error metadata, but keep account keys, certificate keys, EAB HMAC +values, and authorization headers out of logs. + +## Missing DNS solver + +`ACME.DnsSolverNotRegistered` means the descriptor's `type` has no registered +factory in the stack's provider context. For Cloudflare, register both providers: + +```typescript +const providers = Layer.mergeAll(ACME.providers(), Cloudflare.providers()); +``` + +Pass this layer as the stack's `providers`. For runtime issuance, use +`acmeDnsSolver(dns)` with a bound DNS client instead of passing the deploy-time +descriptor returned by `AcmeSolver`. + +## DNS API access fails + +`ACME.DnsSolverError` wraps a failure publishing or cleaning up a challenge. +Check the zone ID, credential scope, and whether the DNS name belongs to that +zone. An API credential valid for one Cloudflare account is not necessarily +valid for another account's zone. + +For runtime `WriteDnsHttp`, the deployment identity must also be allowed to +create the scoped token. A failed cleanup is operationally significant even +if the CA validated the name; inspect the TXT records and retry cleanup safely. + +## TXT propagation times out + +`ACME.DnsPropagationTimeout` identifies the challenge name that was not observed +within the configured budget. Check authoritative delegation, the zone ID, the +exact TXT value, and whether there is an existing CNAME at that name. Public +recursive DNS may still have a negative or previous-value cache entry. + +The deploy-time Cloudflare timeout defaults to 90 seconds and includes the +60-second post-visibility delay. A timeout shorter than the delay cannot work. +Lookup attempts are also bounded; increasing `timeout` alone does not remove +that cap. See [DNS validation](/acme/dns-validation#adjust-the-propagation-budget). + +## The CA rejects a challenge + +`ACME.ChallengeFailed` carries the identifier, CA problem type, and detail. +`ACME.OrderInvalid` carries the order URL and reported problems. A successful +DNS API write does not prove that every CA validator sees the record. + +For an "incorrect TXT record" failure, inspect stale records and cached values +before shortening propagation delays or retrying. Check CAA policy if the CA +reports that it is not authorized to issue. Wildcards require DNS-01 support; +`ACME.ChallengeUnsupported` reports the challenge types the CA offered instead. + +## An order times out + +`ACME.OrderTimeout` means polling did not reach a terminal state within the +implementation's bounded polling budget. Keep the order URL and status for +investigation. The CA may finish processing later; immediately starting another +order can consume additional issuance allowance without resolving the original +problem. + +## Rate limits + +The SDK exposes `AcmeRateLimited` and an optional `retryAfter` Effect `Duration`. +Honor that hint when scheduling the next attempt. The [runtime Worker example](/acme/runtime#assemble-the-worker) +returns HTTP 429 and a `Retry-After` header when the hint is available. + +SDK retries apply to individual protocol requests, not a durable end-to-end +certificate job. If you need protocol errors to surface without the default +request retries, add `@distilled.cloud/acme` as a direct dependency and provide +its retry policy around the operation: + +```typescript +import * as Acme from "@distilled.cloud/acme"; + +const issued = yield* issuer.issue({ + identifiers: ["api.example.com"], + solver, +}).pipe(Acme.Retry.none); +``` + +Disabling retries does not bypass the CA's limits. Keep renewal jobs serialized, +reuse valid stored certificates, and avoid an unbounded retry around `issue`. + +## Staging certificates are untrusted + +A Let's Encrypt staging certificate can be correctly issued and still fail +browser trust checks. Use staging to validate your flow, then switch to a +production CA in the intended stage. Uploading a staging chain to a public +consumer does not make it trusted. + +## ZeroSSL rejects account registration + +Confirm that `eab.keyId` and `eab.hmacKey` are the matching CA-issued pair. The +HMAC value is not your ZeroSSL API key. Keep its base64url encoding intact, load +it as a redacted secret, and verify that the account uses `ACME.ZeroSSL` rather +than another CA's directory. + +## A CA works locally but fails in a Worker + +Check connectivity from the deployed host, not only from your laptop. Live tests +encountered Let's Encrypt TLS failures from Cloudflare Workers, so the deployed +example uses ZeroSSL. For private CAs, `trustedRoot` currently relies on Bun's +fetch TLS option and is not a portable Worker trust-store override. + +A Cloudflare runtime solver waits 60 seconds by default. Ensure the request and +client remain alive long enough. Use a durable job design for issuance that must +continue independently of a disconnected HTTP client. + +## A deployed certificate has not renewed + +Check that a deployment actually ran against the correct stack and stage with +access to the same state. Renewal is evaluated on deployment; it has no +background scheduler. Verify that `notAfter` is within `renewBefore`, then check +whether the consumer loaded the updated output. + +The resource's read operation uses persisted certificate attributes rather than +checking revocation status at the CA. If you revoked a certificate out of band, +do not assume the next read will detect it and issue a replacement automatically. + +## Certificate and private key do not match + +Use the certificate's `chain` and `privateKey` from the same issuance result. +The account private key signs ACME requests and cannot serve as the TLS key. +After renewal, update both values together and reload the TLS consumer. diff --git a/.agents/skills/alchemy/references/acme/using-certificates.md b/.agents/skills/alchemy/references/acme/using-certificates.md new file mode 100644 index 00000000..ce32f10c --- /dev/null +++ b/.agents/skills/alchemy/references/acme/using-certificates.md @@ -0,0 +1,189 @@ + + +# Using certificates + +> Upload an ACME certificate to Fly or pass its chain and private key to another TLS consumer. + +Issuance creates certificate material; it does not create an HTTPS endpoint. +A TLS consumer must receive the matching chain and private key, present the +certificate for the correct hostname, and reload replacements after renewal. + +## Choose the right output + +| Output | Contents | Typical use | +| --- | --- | --- | +| `certificate` | Leaf certificate PEM | Revocation or certificate inspection | +| `chain` | Full PEM chain, leaf first | TLS server or certificate upload | +| `privateKey` | Redacted PKCS#8 private key PEM | TLS server or certificate upload | +| `notAfter` | ISO 8601 expiry | Monitoring and renewal scheduling | +| `serial` | Certificate serial number | Identifying the installed version | + +The account's `privateKey` is a different key, used to sign ACME requests. A TLS +consumer needs the **certificate's** private key, never the account's key. + +## Register Fly's provider + +Start with the [Getting started](/acme/getting-started) stack and add Fly: + +```diff lang="typescript" ++import * as Fly from "alchemy/Fly"; + +- providers: Layer.mergeAll(ACME.providers(), Cloudflare.providers()), ++ providers: Layer.mergeAll( ++ ACME.providers(), ++ Cloudflare.providers(), ++ Fly.providers(), ++ ), +``` + +Connect your Fly account using [Fly setup](/fly/setup). The ACME account and DNS +solver remain independent of Fly; only the upload resource uses Fly credentials. + +## Create the destination App + +Inside the stack Effect, before the upload: + +```typescript +const app = yield* Fly.App("Site", {}); +``` + +Use your application's existing `App` instead when it already owns the TLS +endpoint. Creating an App alone does not start a service or allocate public IPs. + +## Upload the certificate + +After resolving the ACME certificate: + +```typescript +yield* Fly.Certificate("SiteTls", { + app, + hostname: "example.com", + kind: "custom", + fullchain: certificate.chain, + privateKey: certificate.privateKey, +}); +``` + +The resource outputs establish a dependency on issuance. After an ACME renewal +changes the chain and key, the same deployment updates the Fly upload. Pass the +redacted key directly; the provider handles unwrapping it for the API. + +## Review the complete upload stack + +This example uses Let's Encrypt **production** because the destination is a +public TLS consumer. First verify DNS issuance with the staging-only getting +started example. Replace the hostname, contact address, and zone configuration +with your own values: + +```typescript +import * as Alchemy from "alchemy"; +import * as ACME from "alchemy/ACME"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Fly from "alchemy/Fly"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export default Alchemy.Stack( + "SiteCertificates", + { + providers: Layer.mergeAll( + ACME.providers(), + Cloudflare.providers(), + Fly.providers(), + ), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const app = yield* Fly.App("Site", {}); + const account = yield* ACME.Account("Issuer", { + ca: ACME.LetsEncrypt, + contact: ["mailto:ops@example.com"], + termsOfServiceAgreed: true, + }); + const certificate = yield* ACME.Certificate("Site", { + account, + identifiers: ["example.com"], + solver: Cloudflare.DNS.AcmeSolver({ + zoneId: Config.String("CLOUDFLARE_ZONE_ID"), + }), + }); + yield* Fly.Certificate("SiteTls", { + app, + hostname: "example.com", + kind: "custom", + fullchain: certificate.chain, + privateKey: certificate.privateKey, + }); + return { appName: app.appName, expires: certificate.notAfter }; + }), +); +``` + +## Make the hostname reachable + +Follow [Fly IPs & certificates](/fly/networking) to publish a Service, allocate +public IPs, and point the hostname's DNS records at the App. A successful +certificate upload is separate from traffic routing. Check the certificate +served by `https://example.com` after the routing is ready. + +For ordinary Fly-managed certificates, `Fly.Certificate` with `kind: "acme"` +lets Fly handle issuance. Use this independent `ACME.Certificate` plus +`kind: "custom"` combination when you need control over the issuing CA or DNS +validation and will operate renewal yourself. + +## Pass outputs to another resource + +Alchemy resource props accept outputs directly. If another provider accepts PEM +chain and key inputs, pass `certificate.chain` and `certificate.privateKey` to +those fields just as in the Fly upload. Follow that provider's expected chain +format and supported key algorithms. + +For a consumer outside Alchemy, build an explicit secure delivery process. +Store the chain and key as a matching pair, restrict key access, reload the TLS +service after renewal, and verify the certificate it serves. Exporting a PEM +once does not subscribe the consumer to later resource updates. + +## Upload from an application + +In an initialized Fly Service or Worker, bind the destination App separately +from the ACME account: + +```typescript +const certificates = yield* Fly.WriteCertificates(Site); +``` + +Provide `Fly.WriteCertificatesHttp` on the host Effect. `Site` is the same App +resource your service uses; the binding transports the necessary app identity +and scoped credentials to runtime. + +## Install a runtime-issued certificate + +After `issuer.issue` returns inside a request or job: + +```typescript +yield* certificates.upload({ + hostname: "example.com", + fullchain: issued.chain, + privateKey: issued.privateKey, +}); +``` + +Use the [runtime issuance guide](/acme/runtime) for the account and DNS bindings. +The runtime upload is not a `Fly.Certificate` stack resource. Your application +must track and renew it; avoid having a deploy-time resource and a runtime job +both manage the same App/hostname. + +## Keep removal and revocation separate + +Removing a Fly certificate stops that App from using it but does not revoke it +at the issuing CA. Revoking at the CA does not remove the Fly upload. For a +runtime-managed certificate, replace or remove the consumer copy first, then +call `issuer.revoke` according to your retirement policy. + +See [Renewal & revocation](/acme/renewal), the +[Fly Certificate reference](/providers/fly/certificate), and +[WriteCertificates reference](/providers/fly/writecertificates) for the separate +lifecycle controls. diff --git a/.agents/skills/alchemy/references/apis/INDEX.md b/.agents/skills/alchemy/references/apis/INDEX.md new file mode 100644 index 00000000..bcb20270 --- /dev/null +++ b/.agents/skills/alchemy/references/apis/INDEX.md @@ -0,0 +1,10 @@ +# apis index + +4 pages. Every Function and Server returns { fetch, ...rpcs } — schemaless typed calls are the default for internal communication; Effect RPC and Effect HTTP add schemas where data crosses a trust boundary. + +| Page | File | Covers | +| --- | --- | --- | +| APIs | `_overview.md` | Every Function and Server returns { fetch, ...rpcs } — schemaless typed calls are the default for internal communication; Effect RPC and Effect HTTP add schemas where data crosses a trust boundary. | +| Effect HTTP | `effect-http.md` | Schema-validated REST endpoints with an rpc-like typed interface — for trust boundaries where consumers want a plain HTTP client. | +| Effect RPC | `effect-rpc.md` | Schema-first RPC for trust boundaries — declare procedures, construct handler Layers, derive typed clients from the same schema. | +| Schemaless RPC | `schemaless.md` | The pattern behind typed, schema-free RPC — what an RPC member may be, how the typed client arises, how calls travel over the wire, and where the limits are. | diff --git a/.agents/skills/alchemy/references/apis/_overview.md b/.agents/skills/alchemy/references/apis/_overview.md new file mode 100644 index 00000000..08c35abd --- /dev/null +++ b/.agents/skills/alchemy/references/apis/_overview.md @@ -0,0 +1,87 @@ + + +# APIs + +> Every Function and Server returns { fetch, ...rpcs } — schemaless typed calls are the default for internal communication; Effect RPC and Effect HTTP add schemas where data crosses a trust boundary. + +Every Function and Server in alchemy returns `{ fetch, ...rpcs }` from its +[Effectful Constructor](/infrastructure-as-effects/runtime#the-effectful-constructor) — +you already saw the "return what you expose" half. The `rpcs` are the other +half: plain functions returning `Effect` or `Stream`, no schema, no +decorators: + +```typescript +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; + +export default class BindingTargetWorker extends Cloudflare.Worker()( + "BindingTargetWorker", + { + main: import.meta.url, + }, + Effect.gen(function* () { + return { + greet: (name: string) => Effect.succeed(`hello ${name}`), + fetch: Effect.gen(function* () { + return HttpServerResponse.text("hello from BindingTargetWorker"); + }), + }; + }), +) {} +``` + +Any other resource that binds this Worker gets a **fully typed client**: +`yield* target.greet("world")` is an ordinary Effect call, the return type is +inferred from the class, and serialization is handled automatically. The same +model holds across every pairing — a Durable Object bound into a Worker, a +Container called from a Durable Object, a MicroVM called from a Lambda +Function. + +That schemaless path is the **default for internal communication**. Schemas +enter only when data crosses a **trust boundary** — a web app, an external +service, anything you don't deploy yourself. + +## The three modalities + +| Modality | Schema | Per-request cost | Use when | Clients | +| ----------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| [Schemaless RPC](/apis/schemaless) | None | Proxy dispatch + structured clone (native Bindings) or JSON args (fetch transport) + an envelope-tag check — zero validation | Internal service-to-service: Worker → Worker, Worker → Durable Object, Durable Object → Container, Lambda → MicroVM | Any resource that binds the class (MicroVMs connect explicitly via `connectMicrovm`) | +| [Effect RPC](/apis/effect-rpc) | `RpcGroup` | Wire-frame parse + Schema decode of the payload + handler + Schema encode of the result — mirrored on the client | Data needs sanitizing or crosses a trust boundary (web app, external service) and consumers are Effect/TypeScript | The schema + a reference to the Fetcher — a Binding or just a URL | +| [Effect HTTP](/apis/effect-http) | `HttpApi` | Same profile as Effect RPC | Same trust-boundary case, but consumers are non-Effect / non-TypeScript | Plain HTTP — real URLs, path params, query strings, headers, content types, `curl` | + +## Choosing + +Default to **Schemaless RPC for anything internal**. There is no schema to +write, no runtime checking, and no per-request validation cost — and internal +callers already get end-to-end types for free, because the client type *is* +the class you bound. + +**Effect RPC and Effect HTTP are for trust boundaries.** They are discouraged +for internal service-to-service calls: every request pays a Schema decode and +encode on both sides, which buys you nothing when you control both ends. Reach +for them when data needs sanitizing — a browser, a partner service, anything +outside your deploy. + +Between the two schema'd modalities the choice is about the **external +consumer**: an Effect/TypeScript client combines your schema with a Fetcher +and gets a typed RPC client — use [Effect RPC](/apis/effect-rpc); anyone else +wants a real HTTP surface they can hit with a plain HTTP client — use +[Effect HTTP](/apis/effect-http). + +The three interoperate on one host: the schema'd modalities build an +`HttpEffect` returned from `fetch`, so a single Worker or Function can serve +schemaless methods internally **and** a schema'd surface externally. + +## Where next + +1. [Schemaless RPC](/apis/schemaless) — the internal default: typed clients, Streams, and error propagation with no schema. +2. [Effect RPC](/apis/effect-rpc) — `RpcGroup` schemas, handler Layers, and clients built from the schema + a Fetcher. +3. [Effect HTTP](/apis/effect-http) — the same idea as real REST endpoints for non-Effect consumers. + +Then see it wired up in the hubs: + +- **Cloudflare** — [Schemaless RPC](/cloudflare/apis/schemaless-rpc), [Effect RPC](/cloudflare/apis/effect-rpc), [Effect HTTP](/cloudflare/apis/effect-http-api) +- **AWS** — [Schemaless RPC](/aws/apis/schemaless-rpc), [Effect RPC](/aws/apis/effect-rpc), [Effect HTTP](/aws/apis/effect-http-api) diff --git a/.agents/skills/alchemy/references/apis/effect-http.md b/.agents/skills/alchemy/references/apis/effect-http.md new file mode 100644 index 00000000..5b37b73d --- /dev/null +++ b/.agents/skills/alchemy/references/apis/effect-http.md @@ -0,0 +1,245 @@ + + +# Effect HTTP + +> Schema-validated REST endpoints with an rpc-like typed interface — for trust boundaries where consumers want a plain HTTP client. + +Effect HTTP (`effect/http-api`) is the same idea as +[Effect RPC](/apis/effect-rpc): define a Schema, construct handler +Layers, return an `HttpEffect` from `fetch`, call it through an +rpc-like typed interface. The difference is what goes on the wire — +real HTTP endpoints, with URLs, path params, query strings, headers, +bodies, and content types. + +The use case is the same trust boundary — data that needs validating +and sanitizing on the way in, exposed to a web app or an external +service. Choose Effect HTTP over Effect RPC when your consumers +aren't Effect (or TypeScript) programs and want a plain HTTP client. +The cost is also the same: every request pays for a schema decode +and encode, so it's equally discouraged for internal +service-to-service calls — that's what +[schemaless RPC](/apis/schemaless) is for. + +## The pieces + +Four files. Three are the API — domain model, endpoint +declarations, and the host that serves them (a Cloudflare Worker or +Lambda Function) — and the fourth is a client that derives +everything from the second: + +```sh +src/ +├── Task.ts # domain model + typed 404 (Schema.Class, TaggedErrorClass) +├── ApiSchema.ts # endpoint declarations — one HttpApi value, imported by server AND clients +├── ApiService.ts # the host — a Worker or Lambda Function; wired on the hub pages — returns { fetch } +└── ApiClient.ts # typed client derived from ApiSchema — no codegen, no string URLs +``` + +The load-bearing file is `src/ApiSchema.ts`: the server implements +it, every typed client is derived from it, and plain HTTP consumers +get its validation for free. + +## Schema + +`src/Task.ts` owns the value that crosses the wire and the error +that crosses with it: + +```typescript +// src/Task.ts +import * as Schema from "effect/Schema"; + +export class Task extends Schema.Class("Task")({ + id: Schema.String, + title: Schema.String, + completed: Schema.Boolean, +}) {} + +export class TaskNotFound extends Schema.TaggedErrorClass()( + "TaskNotFound", + { id: Schema.String }, + { httpApiStatus: 404 }, +) {} +``` + +`Schema.Class` gives you a runtime-validated class with an inferred +TypeScript type — one definition imported by both sides — and +`httpApiStatus: 404` maps the tagged error to a real 404 over plain +HTTP instead of a generic 500. + +## Endpoints + +`src/ApiSchema.ts` declares the contract — endpoints, a group, and +the API value: + +```typescript +// src/ApiSchema.ts +import * as Schema from "effect/Schema"; +import * as HttpApi from "effect/http-api/HttpApi"; +import * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint"; +import * as HttpApiGroup from "effect/http-api/HttpApiGroup"; +import { Task, TaskNotFound } from "./Task.ts"; + +export const getTask = HttpApiEndpoint.get("getTask", "/:id", { + params: Schema.Struct({ id: Schema.String }), + success: Task, + error: TaskNotFound, +}); + +export const createTask = HttpApiEndpoint.post("createTask", "/", { + payload: Schema.Struct({ title: Schema.String }), + success: Task, +}); + +export class TasksGroup extends HttpApiGroup.make("Tasks") + .add(getTask) + .add(createTask) {} + +export class TaskApi extends HttpApi.make("TaskApi").add(TasksGroup) {} +``` + +Endpoints are pure declarations of `(method, path, schemas)`: the +`/:id` path parameter declared under `params` is what makes +`params.id` a typed `string` in both the handler and the client, +request bodies are decoded against the `payload` Schema before your +handler runs (anything that doesn't match gets an automatic 400 with +a structured validation error), and `TaskApi` is a plain value — +importable by the server and every client without dragging any +runtime code across. + +## Handlers + +`HttpApiBuilder.group` wires handlers into the spec: + +```typescript +// src/ApiService.ts — inside the host's Construction phase (a Cloudflare Worker or Lambda Function) +import * as Effect from "effect/Effect"; +import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; +import { TaskApi } from "./ApiSchema.ts"; +import { Task, TaskNotFound } from "./Task.ts"; + +const tasksGroup = HttpApiBuilder.group(TaskApi, "Tasks", (handlers) => + handlers + .handle("getTask", ({ params }) => + // look up the task in storage — the hub guides wire R2/DynamoDB + Effect.fail(new TaskNotFound({ id: params.id })), + ) + .handle("createTask", ({ payload }) => + Effect.succeed( + new Task({ + id: crypto.randomUUID(), + title: payload.title, + completed: false, + }), + ), + ), +); +``` + +`HttpApiBuilder.group` *constructs* a handler Layer — it runs +nothing, which is what makes it safe at Construction/plan time +([Phases](/infrastructure-as-effects/phases)) — and each +handler receives the typed request its endpoint declared: +`params.id` and `payload.title` are `string`s, the handler must +return a `Task`, and the only allowed failure is `TaskNotFound` — a +wrong shape or an undeclared error is a compile error, not a runtime +surprise. + +## Serve it + +Assemble the API Layer and convert it into the `HttpEffect` that +`fetch` expects: + +```typescript +// src/ApiService.ts — end of the Construction phase +import * as Layer from "effect/Layer"; +import * as HttpRouter from "effect/http/HttpRouter"; + +// platform = the host's platform service Layers — differs per host +return { + fetch: yield* HttpRouter.toHttpEffect( + HttpApiBuilder.layer(TaskApi).pipe( + Layer.provide(tasksGroup), + Layer.provide(platform), + ), + ), +}; +``` + +`HttpRouter.toHttpEffect` builds the Layer once at boot and yields +the request handler — the same `{ fetch }` shape a Worker or a +Lambda Function with `functionUrl: true` serves. The `platform` Layers are +the host-specific part of the wiring — see +[Workers](/cloudflare/apis/effect-http-api) and +[Lambda](/aws/apis/effect-http-api) for the exact set, plus storage +bindings and deploy (and CORS, on Workers). + +## Plain HTTP for everyone else + +Deploy it and the payoff over Effect RPC is immediate: the wire +format is ordinary REST, so consumers need nothing but an HTTP +client. + +```sh +curl -X POST https:/// \ + -H "Content-Type: application/json" \ + -d '{"title": "Write docs"}' +# → {"id":"...","title":"Write docs","completed":false} + +curl https:/// +# → {"id":"...","title":"Write docs","completed":false} +``` + +Invalid payloads get automatic 400 responses with structured +validation errors, and a missing task comes back as a real 404 — the +`httpApiStatus` declared on `TaskNotFound` — in any language, with +any HTTP library, against the same validated contract. + +## A typed client + +For consumers that *are* Effect programs, the same `TaskApi` value +drives a fully typed client — no codegen, no string URLs: + +```typescript +// src/ApiClient.ts +import * as Effect from "effect/Effect"; +import * as HttpApiClient from "effect/http-api/HttpApiClient"; +import { TaskApi } from "./ApiSchema.ts"; + +const program = Effect.gen(function* () { + const client = yield* HttpApiClient.make(TaskApi, { + baseUrl: process.env.TASK_API_URL!, + }); + + const created = yield* client.Tasks.createTask({ + payload: { title: "Write docs" }, + }); + + const task = yield* client.Tasks.getTask({ + params: { id: created.id }, + }); +}); +``` + +Request keys mirror the declarations — `payload` for bodies, +`params` for path parameters — and `client.Tasks.getTask` returns +`Effect`: the 404 arrives as a +typed value you pattern-match on, not a status code you interpret. +The client can also ride a Binding instead of a URL — see the +[Durable Object bridge](/cloudflare/apis/effect-http-api#bridge-the-do-into-a-typed-client) +on the Workers page. + +## Where next + +- [Effect HTTP on Workers](/cloudflare/apis/effect-http-api) — full + Worker wiring: R2 storage, the `HttpPlatform` stub, CORS, and the + DO sub-API bridge. +- [Effect HTTP on Lambda](/aws/apis/effect-http-api) — full Lambda + wiring: DynamoDB bindings, IAM, and a typed end-to-end test. +- [Effect RPC](/apis/effect-rpc) — same trust boundary, leaner wire + protocol, for consumers that are Effect programs. +- [Schemaless RPC](/apis/schemaless) — the default for internal + calls: typed clients with no schema and no per-request validation. +- [REST API (API Gateway v1)](/aws/apis/api-gateway) — put API + Gateway in front instead of a Function URL. diff --git a/.agents/skills/alchemy/references/apis/effect-rpc.md b/.agents/skills/alchemy/references/apis/effect-rpc.md new file mode 100644 index 00000000..5511479c --- /dev/null +++ b/.agents/skills/alchemy/references/apis/effect-rpc.md @@ -0,0 +1,200 @@ + + +# Effect RPC + +> Schema-first RPC for trust boundaries — declare procedures, construct handler Layers, derive typed clients from the same schema. + +Effect RPC is schema-first: every procedure declares `payload`, +`success`, and `error` Schemas, and every request and response is +validated against them. Use it when data crosses a **trust +boundary** — a web app or an external service calling into your +stack. That validation has a per-request price (a frame parse, a +Schema decode of the payload, a Schema encode of the result — +mirrored on the client), so for internal service-to-service calls it +is **discouraged**: [Schemaless RPC](/apis/schemaless) gives you the +same typed client with no schema, no runtime checking, and no +per-request validation cost. + +## The pieces + +An RPC API is five small files: + +```sh +src/ +├── Task.ts # domain model + tagged errors — imported by server AND client +├── ApiSchema.ts # one Rpc.make per procedure, collected into an RpcGroup +├── ApiHandlers.ts # RpcGroup.toLayer handlers + the HttpEffect — host-agnostic +├── ApiService.ts # the host — a Lambda Function or Cloudflare Worker; wired on the hub pages +└── ApiClient.ts # a typed client — the same schema + a Fetcher +``` + +The schema files are plain values with no runtime concerns, so both +the server and every client import them directly. Only +`ApiService.ts` names a cloud — everything above it is host-agnostic. + +## The domain types + +Domain model and error types — pure schemas: + +```typescript +// src/Task.ts +import * as Schema from "effect/Schema"; + +export class Task extends Schema.Class("Task")({ + id: Schema.String, + title: Schema.String, + completed: Schema.Boolean, +}) {} + +export class TaskNotFound extends Schema.TaggedClass()( + "TaskNotFound", + { id: Schema.String }, +) {} + +export class CreateTaskFailed extends Schema.TaggedClass()( + "CreateTaskFailed", + { message: Schema.String }, +) {} +``` + +RPC errors are schema-backed tagged classes — the client receives +them as typed values it can `catchTag` on, not raw HTTP status codes. + +## The procedures + +Each `Rpc.make` declares one procedure; `RpcGroup.make` collects them +into the single value the server and the client will share: + +```typescript +// src/ApiSchema.ts +import * as Schema from "effect/Schema"; +import { Rpc, RpcGroup } from "effect/rpc"; +import { CreateTaskFailed, Task, TaskNotFound } from "./Task.ts"; + +const getTask = Rpc.make("getTask", { + payload: { id: Schema.String }, + success: Task, + error: TaskNotFound, +}); + +const createTask = Rpc.make("createTask", { + payload: { title: Schema.String }, + success: Task, + error: CreateTaskFailed, +}); + +export class TaskRpcs extends RpcGroup.make(getTask, createTask) {} +``` + +`TaskRpcs` is a value-level description — nothing executes yet. + +## Handlers + +`TaskRpcs.toLayer` takes one handler per procedure and produces a +`Layer`: + +```typescript +// src/ApiHandlers.ts +import * as Effect from "effect/Effect"; +import { TaskRpcs } from "./ApiSchema.ts"; +import { Task, TaskNotFound } from "./Task.ts"; + +export const TaskRpcsLive = TaskRpcs.toLayer({ + // stubs — the per-cloud guides back these with real storage Bindings + getTask: ({ id }) => Effect.fail(new TaskNotFound({ id })), + createTask: ({ title }) => + Effect.succeed( + new Task({ id: crypto.randomUUID(), title, completed: false }), + ), +}); +``` + +`toLayer` is pure construction — it builds a value and never runs the +server, so it is safe inside a host's Construction phase, which also +executes at plan time +([Phases](/infrastructure-as-effects/phases)); each handler receives +the decoded payload and returns +an `Effect` that succeeds or fails with the declared schemas, and the +storage-backed bodies live on the hub pages — R2 in +[Effect RPC on Workers](/cloudflare/apis/effect-rpc), DynamoDB in +[Effect RPC on Lambda](/aws/apis/effect-rpc). + +## Serve it + +`RpcServer.toHttpEffect` needs exactly two layers — the handlers and +a serialization: + +```typescript +// src/ApiHandlers.ts (continued) +import * as Layer from "effect/Layer"; +import { RpcSerialization, RpcServer } from "effect/rpc"; + +export const ApiHttpEffect = RpcServer.toHttpEffect(TaskRpcs).pipe( + Effect.provide(Layer.mergeAll(TaskRpcsLive, RpcSerialization.layerJson)), +); +``` + +`toHttpEffect` compiles the group into the `{ fetch }` value every +host serves — it imports nothing cloud-specific. `layerJson` buffers +one body per request and suits plain request/response; streaming +rpcs require `RpcSerialization.layerNdjson` — whichever you pick, +the client's serialization layer must match the server's. A Lambda +Function with `functionUrl: true` or a Cloudflare Worker returns it as +`{ fetch }` — host wiring, storage binding Layers, and deploy live +on [Workers](/cloudflare/apis/effect-rpc) and +[Lambda](/aws/apis/effect-rpc). + +## A typed client + +A client is the schema combined with a **Fetcher** — a plain URL for +external consumers, or a platform Binding when the caller lives in +the same account (see the Workers page). Here is the URL form: + +```typescript +// src/ApiClient.ts +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import { RpcClient, RpcSerialization } from "effect/rpc"; +import { TaskRpcs } from "./ApiSchema.ts"; + +const program = Effect.gen(function* () { + const client = yield* RpcClient.make(TaskRpcs); + + const task = yield* client.createTask({ title: "Write the docs" }); + + const fetched = yield* client.getTask({ id: task.id }); + console.log(fetched.title); +}); + +Effect.runPromise( + program.pipe( + Effect.scoped, + Effect.provide( + RpcClient.layerProtocolHttp({ url: process.env.TASK_API_URL! }).pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(RpcSerialization.layerJson), + ), + ), + ), +); +``` + +`RpcClient.make(TaskRpcs)` derives the whole client from the same +group value — no codegen — with typed errors (`client.getTask` +returns `Effect`); it runs inside `Effect.scoped` +and the serialization layer must match the server's. + +## Where next + +- [Effect RPC on Workers](/cloudflare/apis/effect-rpc) — full Worker + wiring: R2-backed handlers, streaming rpcs, the `RpcWorker` and + `RpcDurableObject` sugar, typed in-account bindings, the DO bridge. +- [Effect RPC on Lambda](/aws/apis/effect-rpc) — full Lambda wiring: + DynamoDB bindings, IAM, Function URL, deploy. +- [Effect HTTP](/apis/effect-http) — the same trust boundary as real + REST endpoints, for non-Effect consumers. +- [Schemaless RPC](/apis/schemaless) — the default for internal + calls. diff --git a/.agents/skills/alchemy/references/apis/schemaless.md b/.agents/skills/alchemy/references/apis/schemaless.md new file mode 100644 index 00000000..669c7507 --- /dev/null +++ b/.agents/skills/alchemy/references/apis/schemaless.md @@ -0,0 +1,174 @@ + + +# Schemaless RPC + +> The pattern behind typed, schema-free RPC — what an RPC member may be, how the typed client arises, how calls travel over the wire, and where the limits are. + +Schemaless RPC is the default for internal communication between the +services you deploy together: expose methods on a runtime's +interface, bind it from another resource, and call them through a +fully typed client — no schema, no runtime validation, no +per-request decode cost. + +## The shape + +RPC is a feature of every +[Runtime](/infrastructure-as-effects/runtime) +runtime — the interface its Effectful Constructor returns may +include methods alongside `fetch`: + +```typescript +export default class Greeter extends Cloudflare.Worker()( + "Greeter", + { main: import.meta.url }, + Effect.gen(function* () { + return { + greet: (name: string) => Effect.succeed(`hello ${name}`), + fetch: Effect.gen(function* () { + return HttpServerResponse.text("ok"); + }), + }; + }), +) {} +``` + +The host class here happens to be `Cloudflare.Worker`, but the host +is incidental — the pattern is identical on every runtime. This page +covers what members are allowed, how the typed client arises, what +crosses the wire, and where the limits are. + +## What a member may be + +An RPC-callable member is a **function returning an Effect**: + +```typescript +return { + greet: (name: string) => Effect.succeed(`hello ${name}`), +}; +``` + +…or a **function returning a Stream**, which the caller consumes +with the usual `Stream.*` combinators: + +```diff lang="typescript" +return { + greet: (name: string) => Effect.succeed(`hello ${name}`), ++ tick: (n: number) => ++ Stream.iterate(0, (i) => i + 1).pipe( ++ Stream.take(n), ++ Stream.schedule(Schedule.spaced("100 millis")), ++ ), +}; +``` + +That is the whole rule: a function, with any arguments, returning an +Effect or a Stream. Bare Effect and Stream members also satisfy the +type — `fetch` itself is one — but they serve the host rather than +remote callers, so declare your RPC surface as functions. Typed +failures flow to the caller +([errors over the wire](#errors-over-the-wire) below), and +requirements like `RuntimeContext` are satisfied by the deployed +runtime. + +### What the types reject + +A member that is a plain value, or a function returning a plain value, fails the Shape constraint: + +```typescript +// ❌ rejected — neither member is an Effect, a Stream, or returns one: +return { + version: "1.0", + greet: (name: string) => `hello ${name}`, +}; + +// ✅ accepted: +return { + version: () => Effect.succeed("1.0"), + greet: (name: string) => Effect.succeed(`hello ${name}`), +}; +``` + +The rejection happens at declaration, not at call time — it surfaces as an assignability error on the constructor Effect you pass to the host class, so a shape that compiles is a shape every caller can rely on. + +## The typed client + +There is no schema because the class itself carries the type. `class Greeter extends Cloudflare.Worker()(...)` gives the class a construct signature — `new (_: never): Shape & ...` — that makes its instance type exactly the Shape the constructor Effect returns, and the value you get by yielding the class carries that Shape as a phantom type (`Rpc = { "~alchemy/rpc": Shape }`) that binders read back. Binding the class produces the client: + +```typescript +// in another resource's Effectful Constructor: +const target = yield* Cloudflare.Workers.bindWorker(Greeter); + +// in the interface it returns: +const greeting = yield* target.greet(name); // typed from Greeter's Shape +``` + +`bindWorker` is the Cloudflare Worker-to-Worker binder; each runtime pair has its own bind callable — the hub pages list them, with full callers per pairing. The mental model is the same everywhere: the stub is a Proxy whose get-trap dispatches over the wire. Every property access is a remote call — the only exceptions are the host's own `fetch`/`connect` surface, which pass through to the underlying binding. + +## How a call travels + +One wire protocol, two carriers. Where the platform provides a native structured-clone channel (Cloudflare service bindings and Durable Object stubs, for example), calls ride it directly and values move by structured clone. Everywhere else — Containers and MicroVMs among them — a generic fetch transport carries the call: each method invocation is a `POST {base}/__rpc__/{name}` whose body is the JSON-encoded argument array, and the response is a JSON value, a JSON error envelope, or an NDJSON stream flagged by a response header: + +```typescript +// packages/alchemy/src/Rpc.ts +/** Path prefix under which RPC methods are dispatched. */ +export const RPC_PATH_PREFIX = "/__rpc__/"; +/** Response header flag marking an NDJSON streamed body (vs a JSON value). */ +export const RPC_STREAM_HEADER = "x-alchemy-rpc-stream"; +``` + +Per-call protocol overhead is sub-millisecond — benchmarked in the wire-protocol test suite. + +## Streams + +A stub call's return value is *both* an Effect and a Stream. The stub can't know synchronously which one the remote method returns — the call is asynchronous — yet its declared type mirrors the remote Shape verbatim, so the single value it hands back satisfies both interfaces: + +```typescript +// one stub, both call shapes: +const greeting = stub.greet(name); // value method — yield* it as an Effect +const firstFive = stub.tick(n).pipe(Stream.take(5)); // streaming method — pipe it as a Stream +``` + +Streams are pulled lazily under backpressure: an infinite remote Stream can be `Stream.take(5)`-ed, and the server stops producing when the client stops consuming. A returned Stream can be piped through any `Stream.*` combinator, including straight onto an HTTP response. Byte streams (`Uint8Array`) round-trip alongside JSON object streams. A failure mid-stream travels as a trailing error marker and surfaces on the consumer as `RpcRemoteStreamError` with the encoded inner error on `.error`; values emitted before the failure are delivered. + +## Errors over the wire + +Typed failures cross the wire intact — `Effect.fail(new MyTaggedError(...))` on the callee is recoverable on the caller: + +```typescript +class KeyMissing extends Data.TaggedError("KeyMissing")<{ + readonly key: string; +}> {} + +// callee — a method that fails with a tagged error: +get: (key: string) => + kv.get(key).pipe( + Effect.flatMap((value) => + value === null + ? Effect.fail(new KeyMissing({ key })) + : Effect.succeed(value), + ), + ), + +// caller — `_tag` and `key` survive the wire; class identity does not: +const value = yield* stub.get(key).pipe( + Effect.catchTag("KeyMissing", (e) => + Effect.succeed(`missing: ${e.key}`), + ), +); +``` + +The codec rules: tagged errors keep `_tag` and all own enumerable fields, and arrive as plain structs — `Effect.catchTag` matches because it keys on `_tag`, but `e instanceof KeyMissing` is `false`; class identity does not survive. Plain `Error`s keep `name`, `message`, and `stack`. Calling an unknown method fails with `RpcCallError`. Defects are not enveloped — the call rejects and surfaces as `RpcCallError`. + +## Limits, and why there is no validation + +Arguments and results must survive the wire codec: structured clone on the native channel, JSON on the fetch transport. Stream elements must be JSON values or `Uint8Array` (binary is base64-tagged on the fetch transport so it round-trips). Functions and callbacks don't serialize. Class identity is always stripped. And because every property access on a stub dispatches remotely (bar the host's own `fetch`/`connect` pass-through), there are no local fields — the stub is only its methods. + +There is zero runtime validation anywhere in the stub or the server. That is the point — no schema to maintain, no per-request decode cost — and the trade-off: nothing sanitizes what arrives. Both sides are your code, deployed together and typed from the same Shape, which is why that trade is safe internally. At a trust boundary — external callers, versioned APIs, untrusted input — reach for schema-validated [Effect RPC](/apis/effect-rpc) instead. + +## Where next + +- [Schemaless RPC on Cloudflare](/cloudflare/apis/schemaless-rpc) — every Cloudflare pairing: Worker → Worker, Worker → Durable Object, another script's Durable Object, Containers, and async consumers. +- [Schemaless RPC on AWS](/aws/apis/schemaless-rpc) — Lambda → MicroVM, plus what AWS does not yet have a schemaless stub for. +- [Effect RPC](/apis/effect-rpc) — schema-validated RPC for trust boundaries. diff --git a/.agents/skills/alchemy/references/aws/INDEX.md b/.agents/skills/alchemy/references/aws/INDEX.md new file mode 100644 index 00000000..cf7c859f --- /dev/null +++ b/.agents/skills/alchemy/references/aws/INDEX.md @@ -0,0 +1,115 @@ +# aws index + +54 pages. Build AWS applications with Alchemy — a runtime (usually Lambda) plus typed resources, wired together by bindings that mint least-privilege IAM policies. + +| Page | File | Covers | +| --- | --- | --- | +| AWS | `_overview.md` | Build AWS applications with Alchemy — a runtime (usually Lambda) plus typed resources, wired together by bindings that mint least-privilege IAM policies. | +| Local development | `local-development.md` | alchemy dev runs your AWS stack on your machine — Lambda in Docker containers, local S3/DynamoDB/SQS, event sources firing, hot reload — with no AWS account or credentials required. | +| Setup | `setup.md` | Connect Alchemy to AWS with SSO, stored access keys, or CI environment credentials. | + +## ai/ + +| Page | File | Covers | +| --- | --- | --- | +| Bedrock & Effect AI | `ai/bedrock.md` | Drive Effect's LanguageModel service with Amazon Bedrock models from a Lambda Function — IAM-scoped bindings, streaming, and tool calling over the Converse API. | + +## apis/ + +| Page | File | Covers | +| --- | --- | --- | +| REST API (API Gateway v1) | `apis/api-gateway.md` | Expose a Lambda with a regional Amazon API Gateway REST API using RestApi, Resource, Method, Deployment, and Stage primitives. | +| Effect HTTP API on Lambda | `apis/effect-http-api.md` | Build a schema-validated HTTP API with Effect's HttpApi module and deploy it as an AWS Lambda Function behind a Function URL. | +| Effect RPC on Lambda | `apis/effect-rpc.md` | Build a typed RPC API with Effect's Rpc module and deploy it as an AWS Lambda Function behind a Function URL. | +| Schemaless RPC | `apis/schemaless-rpc.md` | Typed RPC from a Lambda Function into a MicroVM with no schema — declare the image's Shape, connect, get the client. | + +## compute/ + +| Page | File | Covers | +| --- | --- | --- | +| Choosing a runtime | `compute/choosing-a-runtime.md` | Lambda vs ECS vs EKS vs EC2 for Alchemy apps — cold starts, cost shape, packaging, and how much of each Alchemy currently covers. | +| EC2 | `compute/ec2.md` | Launch virtual machines with the Instance resource — as a raw compute primitive, or hosting a bundled long-lived Effect program served straight off the box. | +| ECS | `compute/ecs.md` | Run containers on AWS with ECS and Fargate — Task definitions that run to completion, Services that keep containers running behind a load balancer, with images bundled from an Effect program, built from your Dockerfile, or mirrored from a registry. | +| EKS | `compute/eks.md` | Stand up an EKS Auto Mode cluster on a Network and run containers on it — Deployments for servers, Jobs for run-to-completion work, Manifests for everything else. No YAML, no kubectl. | +| HyperPod | `compute/hyperpod.md` | Provision SageMaker HyperPod clusters — Slurm or EKS orchestrated — and run ML workloads on them with sbatch, raw manifests, or effectful Jobs with task governance. | +| Lambda | `compute/lambda.md` | Stand up an AWS Lambda Function from a single Effect, expose it over a Function URL, and call it from a test. | +| Lambda MicroVMs | `compute/microvms.md` | Build a Firecracker MicroVM image from TypeScript or a Dockerfile, then launch and drive isolated stateful instances from a Lambda Function with typed lifecycle bindings and RPC. | + +## data/ + +| Page | File | Covers | +| --- | --- | --- | +| Drizzle + Aurora PostgreSQL | `data/drizzle-aurora.md` | Drizzle on Lambda with private Aurora PostgreSQL, IAM authentication, verified TLS, and reviewed SQL migrations. | +| Drizzle + Aurora DSQL | `data/drizzle-dsql.md` | Drizzle on Lambda with Aurora DSQL, a non-admin database role, verified TLS, and an explicit DSQL migration boundary. | +| DynamoDB | `data/dynamodb.md` | Add a DynamoDB Table, bind GetItem and PutItem to your Lambda, and serve a typed key/value HTTP API backed by DynamoDB. | +| RDS & Aurora | `data/rds.md` | Stand up an Aurora cluster in one call with the Aurora helper, connect from Lambda over the Connect binding with pg, or skip connections entirely with the Data API. | +| S3 | `data/s3.md` | Add an S3 Bucket to your Stack, bind PutObject and GetObject as runtime capabilities, and let Alchemy mint the IAM policy for you. | + +## email/ + +| Page | File | Covers | +| --- | --- | --- | +| Email receiving | `email/receiving.md` | Receive inbound email with SES — rule sets and rules that store mail in S3, fan out to SNS, invoke a Lambda, or bounce it, plus IP filters and the active rule set pointer. | +| Sending & managing email | `email/sending.md` | Send email with SES — verify identities, send from a Lambda with the SendEmail binding, manage contact lists, tenants, dedicated IP pools, account settings, and deliverability insights. | + +## frontend/ + +| Page | File | Covers | +| --- | --- | --- | +| Astro | `frontend/astro.md` | Deploy an Astro app to AWS with AWS.Website.Astro — SSR on a streaming Lambda Function URL, static output on S3 + CloudFront, and Astro's own dev server under alchemy dev. | +| Foldkit | `frontend/foldkit.md` | Deploy a Foldkit app to AWS with AWS.Website.Foldkit — the client build on S3 + CloudFront, SPA deep links by default, and Foldkit's own Vite dev server under alchemy dev. | +| Full-stack TanStack Start + RPC + Drizzle | `frontend/full-stack-tanstack-rpc-drizzle.md` | Build a reactive full-stack app on AWS — a TanStack Start UI on CloudFront and Lambda that drives an Effect RPC Lambda over Drizzle and Aurora DSQL, with browser state wired through Effect 4's native atom RPC. | +| Next.js | `frontend/nextjs.md` | Deploy a Next.js app to AWS with AWS.Website.Nextjs — the OpenNext serverless topology with streaming SSR, image optimization, and ISR wiring, plus next dev under alchemy dev. | +| Nuxt | `frontend/nuxt.md` | Deploy a Nuxt app to AWS with AWS.Website.Nuxt — nitro's aws-lambda preset on a streaming Lambda Function URL, S3 assets behind CloudFront, and Nuxt's own dev server under alchemy dev. | +| Octane | `frontend/octane.md` | Deploy an OctaneJS app to AWS with AWS.Website.Octane — SSR on a streaming Lambda Function URL, assets on S3 + CloudFront, and Octane's own Vite dev server under alchemy dev. | +| React Router | `frontend/react-router.md` | Deploy React Router v7 (framework mode) to AWS with AWS.Website.ReactRouter — SSR on a streaming Lambda Function URL, client assets on S3 + CloudFront, and React Router's own Vite dev server under alchemy dev. | +| SolidStart | `frontend/solidstart.md` | Deploy a SolidStart app to AWS with AWS.Website.SolidStart — nitro's aws-lambda preset on a streaming Lambda Function URL, S3 assets behind CloudFront, and SolidStart's own Vite dev server under alchemy dev. | +| Static sites | `frontend/static-site.md` | Ship a static site to S3 + CloudFront with AWS.Website.StaticSite — build-step support, Router composition, and cache invalidation on deploy. | +| SvelteKit | `frontend/sveltekit.md` | Deploy a SvelteKit app to AWS with AWS.Website.SvelteKit — SSR on a streaming Lambda Function URL, assets and prerendered pages on S3 + CloudFront, and Kit's own dev server under alchemy dev. | +| TanStack Start | `frontend/tanstack-start.md` | Deploy TanStack Start (React or Solid) to AWS with AWS.Website.TanStackStart — SSR on a streaming Lambda Function URL, client assets on S3 + CloudFront, and TanStack Start's own Vite dev server under alchemy dev. | +| Vinext | `frontend/vinext.md` | Deploy vinext to AWS with the shared Website API, native local development, and a platform-specific data cache. | +| React SPA | `frontend/vite-spa.md` | Deploy a React single-page app to AWS with AWS.Website.Vite — the Vite build on S3 + CloudFront, deep links answered by index.html out of the box, and Vite's own dev server under alchemy dev. | +| Vite | `frontend/vite.md` | Deploy a plain Vite app to AWS with AWS.Website.Vite — the vite build output on S3 behind CloudFront, SPA fallback at the edge, and Vite's own dev server under alchemy dev. | +| Vue | `frontend/vue.md` | Deploy a Vue single-page app to AWS with AWS.Website.Vite — S3 + CloudFront, deep links that work by default, and Vue's own Vite dev server under alchemy dev. | +| Waku | `frontend/waku.md` | Deploy a Waku app to AWS with AWS.Website.Waku — RSC server on a streaming Lambda Function URL, SSG pages and assets on S3 + CloudFront, and Waku's own dev server under alchemy dev. | +| Websites | `frontend/websites.md` | Deploy Vite, Astro, Next.js, vinext, Nuxt, React Router, SvelteKit, TanStack Start, Waku, Octane, or any static build to AWS with first-class Website resources. | + +## messaging/ + +| Page | File | Covers | +| --- | --- | --- | +| Process DynamoDB Streams | `messaging/dynamodb-streams.md` | Enable a DynamoDB Stream on your table and consume change records as a typed Effect Stream from the same Lambda. | +| EventBridge & Scheduler | `messaging/eventbridge.md` | Route application and AWS events through EventBridge buses and rules, consume them in Lambda as typed streams, and run cron/rate schedules against Lambda, SQS, and ECS with EventBridge Scheduler. | +| Kinesis | `messaging/kinesis.md` | Add a Kinesis Data Stream, publish records from one Lambda, and consume them in order from another — wired through the same Stream-shaped event source. | +| React to S3 Events | `messaging/s3-events.md` | Subscribe a Lambda Function to S3 bucket notifications, process them as an Effect Stream, and let Alchemy wire up the IAM and event-source plumbing. | +| SNS | `messaging/sns.md` | Create an SNS Topic, publish to it from a Lambda with the Publish binding, fan messages out to SQS queues, and consume notifications as a typed Stream. | +| SQS | `messaging/sqs.md` | Add an SQS Queue, publish messages from your Lambda, and consume them from a second consumer Lambda — all wired through Alchemy bindings. | + +## networking/ + +| Page | File | Covers | +| --- | --- | --- | +| VPC & networking | `networking/_overview.md` | The Network helper for a production-shaped VPC in one call, and the full set of EC2 networking primitives — VPCs, subnets, gateways, routes, security groups, and endpoints — for explicit control. | +| Custom domains with Route53 + ACM | `networking/custom-domains.md` | Serve your site or API from your own domain — DNS-validated ACM certificates, Route 53 alias records, and the domain prop on Website resources. | + +## observability/ + +| Page | File | Covers | +| --- | --- | --- | +| CloudWatch | `observability/cloudwatch.md` | Declare CloudWatch dashboards as structured widget documents and metric alarms that fire SNS topics — versioned next to the Lambda functions and queues they observe. | + +## security/ + +| Page | File | Covers | +| --- | --- | --- | +| Secrets & env | `security/secrets-env.md` | Deliver API keys from .env to a Lambda with effect/Config, and graduate to AWS Secrets Manager when the secret is a shared, generated, or rotated cloud resource. | + +## tutorial/ + +| Page | File | Covers | +| --- | --- | --- | +| Part 1: Your First Stack | `tutorial/part-1.md` | Install Alchemy, create a Stack with an AWS S3 Bucket, and deploy it. | +| Part 2: Add a Lambda | `tutorial/part-2.md` | Create an AWS Lambda Function with a public URL, bind the S3 Bucket, and implement GET/PUT routes. | +| Part 3: Testing | `tutorial/part-3.md` | Write integration tests that deploy your stack and make HTTP requests against your live Lambda Function URL. | +| Part 4: Stages | `tutorial/part-4.md` | Deploy isolated dev, staging, and prod instances of your stack with --stage, and tune resources per stage. | +| Part 5: CI/CD | `tutorial/part-5.md` | Set up GitHub Actions for automated AWS deployments and PR previews — with OIDC credentials provisioned as code. | diff --git a/.agents/skills/alchemy/references/aws/_overview.md b/.agents/skills/alchemy/references/aws/_overview.md new file mode 100644 index 00000000..fecfa51a --- /dev/null +++ b/.agents/skills/alchemy/references/aws/_overview.md @@ -0,0 +1,123 @@ + + +# AWS + +> Build AWS applications with Alchemy — a runtime (usually Lambda) plus typed resources, wired together by bindings that mint least-privilege IAM policies. + +An Alchemy app on AWS is one **runtime** — usually a Lambda +Function — plus the **resources** it talks to: tables, +buckets, queues, streams. You wire them together with typed +**bindings**: call `S3.PutObject(bucket)` in your code and +Alchemy attaches the matching least-privilege IAM statement to +the function's role. The code is the policy. + +New here? [Set up credentials](/aws/setup) first, then deploy +your first [Lambda](/aws/compute/lambda). + +## Pick your runtime + +- **[Lambda](/aws/compute/lambda)** — serverless, event-driven functions + with public Function URLs. The primary documented path: every + resource and event source in this section is shown + running on Lambda. +- **[ECS](/aws/compute/ecs)** — long-running containers on Fargate. + Alchemy bundles your Effect program into a Docker image and + runs it as a Cluster + Service + Task. +- **[EKS](/aws/compute/eks)** — managed Kubernetes (Auto Mode). + Deployments, Jobs, raw manifests, and Helm charts in the same + TypeScript program as the cluster — no YAML, no kubectl. +- **[EC2](/aws/compute/ec2)** — full-control virtual machines, with the + complete VPC networking toolkit around them. + +Not sure which? See +[Choosing a runtime](/aws/compute/choosing-a-runtime). + +## Data + +- **[DynamoDB](/aws/data/dynamodb)** — key/value tables with + `GetItem`/`PutItem` bindings and change-data-capture Streams. +- **[S3](/aws/data/s3)** — object storage with `GetObject`/`PutObject` + bindings and bucket event notifications. +- **[RDS & Aurora](/aws/data/rds)** — managed Postgres/MySQL: the + `Aurora` helper stands up the whole cluster in one call, with + a `Connect` binding or the Data API at runtime. + +## Messaging & events + +- **[SQS](/aws/messaging/sqs)** — queues with a `SendMessage` binding and a + `Stream`-shaped Lambda consumer. +- **[SNS](/aws/messaging/sns)** — pub/sub topics with a `Publish` binding + and fan-out to queues and functions. +- **[EventBridge & Scheduler](/aws/messaging/eventbridge)** — event buses, + rules, and cron/rate schedules that invoke your functions. +- **[Kinesis](/aws/messaging/kinesis)** — ordered, sharded data streams + with a `PutRecord` binding and the same `Stream` consumer + surface. + +## Email + +- **[Sending & managing email](/aws/email/sending)** — verify an SES + identity, send from a Lambda with the `SendEmail` binding, and + manage identity policies, contact lists, dedicated IP pools, + tenants, account settings, and deliverability insights. +- **[Receiving inbound email](/aws/email/receiving)** — SES receipt + rule sets and rules that store mail in S3, fan out to SNS, invoke + a Lambda, or bounce it. Region-limited to `us-east-1`, + `us-west-2`, and `eu-west-1`. + +## Security & secrets + +- **[Secrets & env](/aws/security/secrets-env)** — .env values via + Config, Secrets Manager when the secret is shared, generated, + or rotated. + +## Observability + +- **[CloudWatch](/aws/observability/cloudwatch)** — dashboards and metric + alarms, declared in the same Stack as the resources they + watch. + +## Frontend & networking + +- **[Websites](/aws/frontend/websites)** — the frontend block: static + sites and built Vite apps on S3 + CloudFront as a single + `StaticSite` resource. +- **[VPC & networking](/aws/networking)** — the `Network` helper + and the VPC primitives, for when ECS, EKS, or EC2 needs + explicit networking. + +## What are you building? + +| You're building | Reach for | +| ------------------------------------- | ----------------------------------------------------------------------------- | +| An HTTP API | [Lambda](/aws/compute/lambda) + Function URL + [DynamoDB](/aws/data/dynamodb) | +| A typed HTTP API | [Effect HTTP API on Lambda](/aws/apis/effect-http-api) | +| A typed API for external clients | [Effect RPC on Lambda](/aws/apis/effect-rpc) | +| Drive a MicroVM from a Lambda (internal RPC) | [MicroVMs](/aws/compute/microvms) + [Schemaless RPC](/apis/schemaless) | +| An ML training fleet | [HyperPod](/aws/compute/hyperpod) — Slurm or EKS orchestrated, with task governance | +| A REST API with stages/custom domains | [API Gateway](/aws/apis/api-gateway) | +| Your own domain on a site or API | [Custom domains with Route53 + ACM](/aws/networking/custom-domains) | +| A static site | [Deploy a static site](/aws/frontend/static-site) on S3 + CloudFront | +| An event pipeline | [Kinesis](/aws/messaging/kinesis) → Lambda | +| Background jobs | [SQS](/aws/messaging/sqs) + Lambda | +| Scheduled jobs | [EventBridge Scheduler](/aws/messaging/eventbridge) → Lambda | +| A Postgres database | [RDS & Aurora](/aws/data/rds) | +| Kubernetes workloads or Helm charts | [EKS](/aws/compute/eks) | +| Object processing | [S3 events](/aws/messaging/s3-events) | +| Sending transactional email | [SES sending](/aws/email/sending) — identity + `SendEmail` binding | +| Inbound email pipelines | [SES email receiving](/aws/email/receiving) → S3 / SNS / Lambda | +| Change data capture | [DynamoDB Streams](/aws/messaging/dynamodb-streams) | +| API keys or credentials for a function | [Secrets & env](/aws/security/secrets-env) | + +## Where next + +- [Setup](/aws/setup) — install Alchemy and connect your AWS + account. +- [Deploy a static site](/aws/frontend/static-site) and + [Effect HTTP API on Lambda](/aws/apis/effect-http-api) — + end-to-end guides for the two most common app shapes. +- [Providers reference](/providers) — generated API docs for + every AWS resource Alchemy ships, well beyond the blocks + documented here (CloudFront, Route53, IAM, and more). diff --git a/.agents/skills/alchemy/references/aws/ai/bedrock.md b/.agents/skills/alchemy/references/aws/ai/bedrock.md new file mode 100644 index 00000000..923c43c8 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/ai/bedrock.md @@ -0,0 +1,217 @@ + + +# Bedrock & Effect AI + +> Drive Effect's LanguageModel service with Amazon Bedrock models from a Lambda Function — IAM-scoped bindings, streaming, and tool calling over the Converse API. + +Effect's AI surface gives you a single `LanguageModel` service that +every model provider ships as a `Layer`. Once that layer is in scope, +the rest of the surface (`generateText`, `streamText`, `Toolkit`, +`Chat`, …) is provider-agnostic. + +`AWS.Bedrock.LanguageModel` builds that layer on top of Amazon +Bedrock. It speaks Bedrock's [Converse +API](https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference.html) +— the unified messages API that works across all conversational +foundation models (Amazon Nova, Anthropic Claude, Meta Llama, +Mistral, …) — so one binding covers every model on Bedrock. Requests +are signed with the Function's IAM role: there is no API key to +provision, store, or rotate. + +## Bind a model + +`yield* AWS.Bedrock.LanguageModel(model)` in the Function's constructor +phase returns a `LanguageModel` `Layer` and grants the Function +`bedrock:InvokeModel` and `bedrock:InvokeModelWithResponseStream` +scoped to exactly that model: + +```typescript +// src/Api.ts +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; +import { LanguageModel } from "effect/ai"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; + +export default class Api extends AWS.Lambda.Function()( + "Api", + { main: import.meta.url, functionUrl: true }, + Effect.gen(function* () { + const model = yield* AWS.Bedrock.LanguageModel( + "us.amazon.nova-micro-v1:0", + { parameters: { maxTokens: 1024, temperature: 0.7 } }, + ); + + return { + fetch: Effect.gen(function* () { + const response = yield* LanguageModel.generateText({ + prompt: "Say hello.", + }).pipe(Effect.orDie); + return yield* HttpServerResponse.json({ text: response.text }); + }).pipe(Effect.provide(model)), + }; + }).pipe(Effect.provide(AWS.Bedrock.LanguageModelHttp)), +) {} +``` + +`AWS.Bedrock.LanguageModelHttp` is the binding's implementation +layer. Provide it once at the bottom of the constructor's layer chain — it composes +the `Converse` and `ConverseStream` bindings, so it is bundled into +the Lambda and signs requests with the invocation role's credentials. + +A model reference may be a foundation-model id +(`anthropic.claude-sonnet-4-20250514-v1:0`), a cross-region inference +profile id (`us.amazon.nova-micro-v1:0`), or a full Bedrock ARN. +Model access is an account entitlement — enable the model in the +Bedrock console (Model access) before invoking, otherwise calls fail +with `AccessDeniedException`. Many newer models are only invocable +through their cross-region inference profile id, not the bare +foundation-model id. + +## Stream text + +`streamText` maps Bedrock's `ConverseStream` events onto Effect AI's +stream parts (`text-start`, `text-delta`, …, `finish`), one part per +Bedrock event: + +```typescript +import * as Stream from "effect/Stream"; + +const parts = LanguageModel.streamText({ + prompt: "Write a haiku about infrastructure.", +}).pipe(Stream.provide(model)); + +yield* parts.pipe( + Stream.runForEach((part) => + part.type === "text-delta" + ? Effect.sync(() => process.stdout.write(part.delta)) + : Effect.void, + ), +); +``` + +The terminal `finish` part carries the mapped stop reason +(`end_turn` → `stop`, `tool_use` → `tool-calls`, `max_tokens` → +`length`, …) and real token usage from the stream's `metadata` event, +including prompt-cache read/write counts. + +## Call tools + +Effect AI toolkits work unchanged — tool schemas are translated to +Converse `toolConfig` and Bedrock's structured tool-use blocks are +decoded back into typed tool calls and results: + +```typescript +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/ai"; + +const GetWeather = Tool.make("get_weather", { + description: "Get the current weather for a city.", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ temperatureF: Schema.Number }), +}); +const WeatherToolkit = Toolkit.make(GetWeather); + +const response = yield* LanguageModel.generateText({ + prompt: "What's the weather in Seattle?", + toolkit: WeatherToolkit, +}).pipe( + Effect.provide( + WeatherToolkit.toLayer({ + get_weather: ({ city }) => Effect.succeed({ temperatureF: 72 }), + }), + ), + Effect.provide(model), +); + +response.toolResults; // typed results, executed by your handlers +``` + +## Configure parameters at runtime + +Binding is the IAM boundary — *which models* the Function may call +is fixed at deploy time. Everything else is a runtime decision. The +`parameters` passed to the binding are only defaults; scope per-call +overrides onto any call with `AWS.Bedrock.withModelParameters`: + +```typescript +const response = yield* LanguageModel.generateText({ prompt }).pipe( + AWS.Bedrock.withModelParameters({ temperature: 0, maxTokens: 64 }), +); +``` + +It works on streams the same way: + +```typescript +const parts = LanguageModel.streamText({ prompt }).pipe( + Stream.provide(model), + AWS.Bedrock.withModelParameters({ temperature: 0.9 }), +); +``` + +The override is fiber-scoped (a `Context.Reference`), so it applies +to exactly the calls inside the piped region — concurrent requests +with different settings never interfere. Defined fields win over the +binding's defaults; omitted fields fall through. + +`parameters` covers the Converse API's portable `inferenceConfig` +(`maxTokens`, `temperature`, `topP`, `stopSequences`). Anything +model-specific passes through `additionalModelRequestFields` +verbatim — e.g. `{ top_k: 50 }` for Anthropic models. + +## Multiple models + +Bind a list of models to grant IAM for all of them in one layer — +the first is the default, and `modelId` picks per call: + +```typescript +// Construction: IAM for both models, Nova Micro is the default +const model = yield* AWS.Bedrock.LanguageModel([ + "us.amazon.nova-micro-v1:0", + "us.anthropic.claude-sonnet-4-20250514-v1:0", +]); + +// runtime: route this request to Claude, tuned for this call +const response = yield* LanguageModel.generateText({ prompt }).pipe( + AWS.Bedrock.withModelParameters({ + modelId: "us.anthropic.claude-sonnet-4-20250514-v1:0", + maxTokens: 2048, + }), +); +``` + +Separate `LanguageModel(...)` calls also work — each is its own +layer with its own grant — when you'd rather select a model by +providing a different layer per route. + +## Why not an OpenAI-compatible endpoint? + +Bedrock also exposes an OpenAI-compatible Chat Completions endpoint, +but it authenticates with long-lived Bedrock API keys and only covers +a subset of models. The Converse-backed binding uses the Function's +IAM role (SigV4) with a least-privilege grant scoped to the bound +model, works with every conversational model on Bedrock, and needs no +secret material at all. + +## Lower-level access + +The `LanguageModel` binding is built on the raw Converse bindings, +which remain available when you want the wire-level API: + +- [`AWS.Bedrock.Converse`](/providers/aws/bedrock/converse) — single-shot + messages API +- [`AWS.Bedrock.ConverseStream`](/providers/aws/bedrock/conversestream) — + streaming event API +- [`AWS.Bedrock.InvokeModel`](/providers/aws/bedrock/invokemodel) — raw + model-native payloads + +## Where to go next + +- [Effect AI documentation](https://effect.website/docs/ai/introduction) + — the API surface (`generateText`, `streamText`, `Toolkit`, + structured outputs). +- [Lambda](/aws/compute/lambda) — the Function runtime this binding + attaches to. +- [Effect AI on Cloudflare](/cloudflare/ai/effect-ai) — the same + `LanguageModel` programs running on Workers AI. diff --git a/.agents/skills/alchemy/references/aws/apis/api-gateway.md b/.agents/skills/alchemy/references/aws/apis/api-gateway.md new file mode 100644 index 00000000..bd897c26 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/apis/api-gateway.md @@ -0,0 +1,131 @@ + + +# REST API (API Gateway v1) + +> Expose a Lambda with a regional Amazon API Gateway REST API using RestApi, Resource, Method, Deployment, and Stage primitives. + +The [Deploy a Lambda Function](/aws/compute/lambda) tutorial uses a **Function URL** for HTTP. Many teams still use **Amazon API Gateway** (REST, v1) as the front door. + +Alchemy models the v1 REST control plane as separate resources so you can wire integrations, stages, and custom domains explicitly. + +## Try the example + +The repo includes **`examples/aws-rest-api`**: a minimal **regional** REST API with `ANY` on the root and a `{proxy+}` resource, **AWS_PROXY** integration to a Lambda, then **Deployment**, **Stage** (`prod`), and **Lambda.Permission** so API Gateway can invoke the function. + +```sh +cd examples/aws-rest-api +bun install +bun run deploy +``` + +After deploy, open the printed `invokeUrl` in a browser; you should see the Lambda’s text response. + +## Shape of the stack + +Typical order inside `Effect.gen`: + +1. **`AWS.ApiGateway.RestApi`** — creates the API and exposes `restApiId` / `rootResourceId`. +2. **`AWS.ApiGateway.Resource`** — optional path segments (e.g. `{proxy+}`). +3. **`AWS.ApiGateway.Method`** — HTTP verb + integration (`AWS_PROXY` Lambda ARNs use `Output.interpolate` with `arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${functionArn}/invocations`). +4. **`AWS.ApiGateway.Deployment`** — snapshots the current resource graph; change **`triggers`** (or other props) to force a new deployment when only integrations changed. +5. **`AWS.ApiGateway.Stage`** — attaches a deployment to a named stage (`prod`, `dev`, …). +6. **`AWS.Lambda.Permission`** — `principal: "apigateway.amazonaws.com"` and `sourceArn` covering `execute-api` for your API. + +Use **`yield* AWS.AWSEnvironment`** for `region` and `accountId` when building ARNs (as in the example). + +## Private integration (VPC link) + +For **private** HTTP integrations, create a **`AWS.ApiGateway.VpcLink`** with your load balancer target ARNs, then reference `vpcLinkId` as **`integration.connectionId`** with **`connectionType: "VPC_LINK"`** on `AWS.ApiGateway.Method` (`integration.uri` points at your private backend URL). + +```typescript +const link = yield* AWS.ApiGateway.VpcLink("InternalLink", { + name: "internal-nlb", + targetArns: [loadBalancer.loadBalancerArn], +}); + +yield* AWS.ApiGateway.Method("PrivateGet", { + restApiId: api.restApiId, + resourceId: resource.resourceId, + httpMethod: "GET", + integration: { + type: "HTTP_PROXY", + integrationHttpMethod: "GET", + uri: "https://api.internal.example/resource", + connectionType: "VPC_LINK", + connectionId: link.vpcLinkId, + }, +}); +``` + +See also [VpcLink](/providers/aws/apigateway/vpclink) in the generated API reference. + +## Custom domain + +Serve the API from your own hostname instead of the default `execute-api` URL. You need a validated ACM certificate, an **`AWS.ApiGateway.DomainName`**, a **`AWS.ApiGateway.BasePathMapping`**, and a DNS record pointing at the domain's regional target. + +### Request a certificate + +```typescript +const cert = yield* AWS.ACM.Certificate("ApiCertificate", { + domainName: "api.example.com", + hostedZoneId: "Z1234567890", +}); +``` + +With `hostedZoneId` set, the provider creates the DNS validation records in Route 53 and waits for the certificate to be issued. Note that `AWS.ACM.Certificate` always requests the certificate in **`us-east-1`** (the region CloudFront and **EDGE** endpoints require) — a **REGIONAL** domain name needs a certificate in the API's own region, so outside `us-east-1` pass a pre-provisioned regional certificate ARN instead. + +### Create the domain name + +```typescript +const domain = yield* AWS.ApiGateway.DomainName("ApiDomain", { + domainName: "api.example.com", + regionalCertificateArn: cert.certificateArn, + endpointConfiguration: { types: ["REGIONAL"] }, + securityPolicy: "TLS_1_2", +}); +``` + +A regional domain exposes `regionalDomainName` and `regionalHostedZoneId` — the alias target for DNS. (EDGE domains use `certificateArn` and the `distributionDomainName` / `distributionHostedZoneId` attributes instead.) + +### Map the API to the domain + +```typescript +yield* AWS.ApiGateway.BasePathMapping("Root", { + domainName: domain.domainName, + restApiId: api.restApiId, + stage: stage.stageName, +}); +``` + +Omitting `basePath` maps the domain root; set `basePath: "v1"` to serve the stage under `https://api.example.com/v1`. + +### Point DNS at the domain + +```typescript +yield* AWS.Route53.Record("ApiAlias", { + hostedZoneId: "Z1234567890", + name: "api.example.com", + type: "A", + aliasTarget: { + hostedZoneId: domain.regionalHostedZoneId.as(), + dnsName: domain.regionalDomainName.as(), + }, +}); +``` + +An alias **A** record targets the regional endpoint; `.as()` narrows the optional outputs. See [DomainName](/providers/aws/apigateway/domainname) and [BasePathMapping](/providers/aws/apigateway/basepathmapping) in the API reference, and the [custom domains guide](/aws/networking/custom-domains) for the broader pattern (CloudFront, static sites). + +## API reference + +Generated docs for each primitive live under **Providers → AWS → ApiGateway** in the sidebar (for example [RestApi](/providers/aws/apigateway/restapi)). Run `bun generate:api-reference` locally after changing JSDoc on resources. + +## Next steps + +- **Custom domains:** covered [above](#custom-domain); see also the [custom domains guide](/aws/networking/custom-domains). +- **API keys / throttling:** `ApiKey`, `UsagePlan`, `UsagePlanKey`. +- **Authorizers:** `Authorizer` for Cognito or Lambda TOKEN / REQUEST flows. +- **Gateway responses:** `GatewayResponse` for consistent 4xx/5xx bodies. + +HTTP APIs (v2) are a different product; this tutorial is **REST v1** only. diff --git a/.agents/skills/alchemy/references/aws/apis/effect-http-api.md b/.agents/skills/alchemy/references/aws/apis/effect-http-api.md new file mode 100644 index 00000000..ede2be28 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/apis/effect-http-api.md @@ -0,0 +1,485 @@ + + +# Effect HTTP API on Lambda + +> Build a schema-validated HTTP API with Effect's HttpApi module and deploy it as an AWS Lambda Function behind a Function URL. + +Effect HTTP defines real REST endpoints — URLs, path params, query +strings, typed bodies — behind an RPC-like typed interface, which +makes it the natural fit when your consumers aren't Effect (or +aren't TypeScript at all) and want a plain HTTP client. This page is +the Lambda wiring; the concept lives at +[Effect HTTP](/apis/effect-http), and [RPC](/apis) covers choosing +between the RPC styles. + +The [Lambda](/aws/compute/lambda) page serves HTTP with a hand-rolled +`fetch` handler — raw request parsing, manual routing. That works, +but as your API grows you lose type safety at the boundary: request +payloads aren't validated, response shapes aren't enforced, and +errors slip through untyped. + +Effect's `HttpApi` module solves this. You declare endpoints with +schemas for payloads, responses, and errors, then implement handlers +against those schemas. The result is an `HttpEffect` — the same type +a Lambda's `fetch` expects — so it plugs in directly. It's the exact +same module used in the +[Workers version of this guide](/cloudflare/apis/effect-http-api); +the spec and handlers are portable, only the host changes. + +The mental model: + +1. **Define the schema and API outside the Function.** Both are pure + descriptions and can be imported by clients. +2. **Bind resources and construct handlers inside the Function's + Construction phase.** The Construction phase runs at deploy time *and* at cold + start, so we only do pure construction here — bindings, layers, + never per-request work. +3. **Return `{ fetch }`** where `fetch` is an `HttpEffect` produced + by `HttpRouter.toHttpEffect`. That's the value Lambda invokes on + every request. +4. **Deploy** and call the API from a fully typed test. + +A complete, runnable version of this app lives at +[`examples/aws-lambda-httpapi`](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-lambda-httpapi). + +## 1. Define the schema + +Start with a domain model. The schema lives outside the Function so +the same file can be imported by clients without pulling in any +runtime code. + +```typescript +// src/Job.ts +import * as Schema from "effect/Schema"; + +export type JobId = Schema.Schema.Type; +export const JobId = Schema.String.annotate({ + description: "The ID of the job", +}); + +export class Job extends Schema.Class("Job")({ + id: JobId, + content: Schema.String, +}) {} + +export class JobNotFound extends Schema.TaggedErrorClass()( + "JobNotFound", + { id: Schema.String }, + { httpApiStatus: 404 }, +) {} +``` + +`Schema.Class` gives you a runtime-validated class with an inferred +TypeScript type. `Schema.TaggedErrorClass` gives you a typed error +you can fail with from handlers and discriminate against on the +client — `httpApiStatus: 404` tells the HttpApi runtime which status +code to serialize it as. + +## 2. Declare the API + +Endpoints are declarations — they describe `(method, path, query, +payload, success, error)` without implementing anything yet. Putting +them in their own file keeps the spec importable from both the +server *and* a typed client. + +```typescript +// src/JobApi.ts +import * as Schema from "effect/Schema"; +import * as HttpApi from "effect/http-api/HttpApi"; +import * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint"; +import * as HttpApiGroup from "effect/http-api/HttpApiGroup"; +import { Job, JobId, JobNotFound } from "./Job.ts"; + +export const getJob = HttpApiEndpoint.get("getJob", "/", { + success: Job, + error: JobNotFound, + query: { + jobId: JobId, + }, +}); + +export const createJob = HttpApiEndpoint.post("createJob", "/", { + success: JobId, + payload: Schema.Struct({ + content: Schema.String, + }), +}); + +export const JobApi = HttpApi.make("JobApi").add( + HttpApiGroup.make("Jobs").add(getJob, createJob), +); +``` + +Nothing executes yet — `JobApi` is purely a value-level description. +The same constant is what we'll hand to the client at the end of +this guide. + +## 3. Build the Function + +Now wire it up. Create `src/JobFunction.ts` with an empty Construction +phase and a public Function URL: + +```typescript +// src/JobFunction.ts +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; + +export default class JobFunction extends AWS.Lambda.Function()( + "JobFunction", + { main: import.meta.url, functionUrl: true }, + Effect.gen(function* () { + return {}; + }), +) {} +``` + +The generator is the **Construction phase**. It runs at *deploy time* (when +Alchemy plans the stack and collects bindings into IAM policies and +environment variables) and again *inside the deployed Lambda* at +cold start, where the same yields resolve to live clients. Anything +you `yield*` here must be safe in both contexts — resource bindings, +layer construction, never per-request work. + +### 3a. Declare the table and bind operations + +Jobs need somewhere durable to live. Yield a DynamoDB `Table` inside +the constructor to declare the resource, then bind the two operations the +handlers will need: + +```diff lang="typescript" + Effect.gen(function* () { ++ const table = yield* AWS.DynamoDB.Table("JobsTable", { ++ partitionKey: "id", ++ attributes: { id: "S" }, ++ }); ++ ++ const getItem = yield* AWS.DynamoDB.GetItem(table); ++ const putItem = yield* AWS.DynamoDB.PutItem(table); + + return {}; + }), +``` + +Each binding does double duty: at deploy time it attaches a +least-privilege IAM statement to the function's execution role +(`GetItem` grants exactly `dynamodb:GetItem` on this table's ARN); +at runtime it returns a typed callable that injects the table name +automatically. + +### 3b. Provide the binding implementations + +The bindings are contracts; their implementations are Layers that +must be bundled into the function. Provide them on the constructor Effect: + +```diff lang="typescript" + Effect.gen(function* () { + // ... +- }), ++ }).pipe( ++ Effect.provide( ++ Layer.mergeAll( ++ AWS.DynamoDB.GetItemHttp, ++ AWS.DynamoDB.PutItemHttp, ++ ), ++ ), ++ ), +``` + +`GetItemHttp` / `PutItemHttp` implement the binding contracts over +DynamoDB's HTTP API, signed with the Lambda's execution role — no +credentials to wire up. + +### 3c. Construct the handler group + +`HttpApiBuilder.group` *constructs* a `Layer` that wires handlers +into the API spec. It's pure — it doesn't run them — so it's safe to +build inside the constructor. The handlers close over the `getItem` / `putItem` +bindings from step 3a: + +```diff lang="typescript" + const getItem = yield* AWS.DynamoDB.GetItem(table); + const putItem = yield* AWS.DynamoDB.PutItem(table); + ++ const jobsGroup = HttpApiBuilder.group(JobApi, "Jobs", (handlers) => ++ handlers ++ .handle("getJob", ({ query }) => ++ getItem({ Key: { id: { S: query.jobId } } }).pipe( ++ Effect.orDie, ++ Effect.flatMap(({ Item }) => ++ Item ++ ? Effect.succeed( ++ new Job({ ++ id: Item.id?.S ?? query.jobId, ++ content: Item.content?.S ?? "", ++ }), ++ ) ++ : Effect.fail(new JobNotFound({ id: query.jobId })), ++ ), ++ ), ++ ) ++ .handle("createJob", ({ payload }) => ++ Effect.gen(function* () { ++ const jobId = crypto.randomUUID(); ++ yield* putItem({ ++ Item: { ++ id: { S: jobId }, ++ content: { S: payload.content }, ++ }, ++ }); ++ return jobId; ++ }).pipe(Effect.orDie), ++ ), ++ ); + + return {}; +``` + +Each handler receives a typed request. `query.jobId` is a `string` +because that's what the endpoint declared, and the return type must +satisfy `Job` (or fail with `JobNotFound`). Mismatches are caught at +compile time. `Effect.orDie` converts DynamoDB's infrastructure +errors into defects — the HttpApi runtime turns those into 500s — +keeping the typed error channel reserved for `JobNotFound`. + +### 3d. Return `{ fetch }` + +The return value of the constructor is the Function's runtime surface — for an +HTTP Lambda that means an object with a `fetch` field. +`HttpApiBuilder.layer(JobApi)` assembles the API from the handler +group, and `HttpRouter.toHttpEffect` converts that Layer into the +`HttpEffect` that `fetch` expects: + +```diff lang="typescript" + return { +- }; ++ fetch: yield* HttpRouter.toHttpEffect( ++ HttpApiBuilder.layer(JobApi).pipe(Layer.provide(jobsGroup)), ++ ), ++ }; + }).pipe( + Effect.provide( + Layer.mergeAll( + AWS.DynamoDB.GetItemHttp, + AWS.DynamoDB.PutItemHttp, ++ HttpPlatform.layer, ++ Etag.layer, + ), + ), + ), +``` + +`HttpPlatform.layer` and `Etag.layer` are platform services the +builder needs (content negotiation, ETag generation) — they join the +binding implementations in the same `Layer.mergeAll`. + +### Putting it together + +Here's the complete `src/JobFunction.ts`: + +```typescript +// src/JobFunction.ts +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Etag from "effect/http/Etag"; +import * as HttpPlatform from "effect/http/HttpPlatform"; +import * as HttpRouter from "effect/http/HttpRouter"; +import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; +import { Job, JobNotFound } from "./Job.ts"; +import { JobApi } from "./JobApi.ts"; + +export default class JobFunction extends AWS.Lambda.Function()( + "JobFunction", + { main: import.meta.url, functionUrl: true }, + Effect.gen(function* () { + const table = yield* AWS.DynamoDB.Table("JobsTable", { + partitionKey: "id", + attributes: { id: "S" }, + }); + + const getItem = yield* AWS.DynamoDB.GetItem(table); + const putItem = yield* AWS.DynamoDB.PutItem(table); + + const jobsGroup = HttpApiBuilder.group(JobApi, "Jobs", (handlers) => + handlers + .handle("getJob", ({ query }) => + getItem({ Key: { id: { S: query.jobId } } }).pipe( + Effect.orDie, + Effect.flatMap(({ Item }) => + Item + ? Effect.succeed( + new Job({ + id: Item.id?.S ?? query.jobId, + content: Item.content?.S ?? "", + }), + ) + : Effect.fail(new JobNotFound({ id: query.jobId })), + ), + ), + ) + .handle("createJob", ({ payload }) => + Effect.gen(function* () { + const jobId = crypto.randomUUID(); + yield* putItem({ + Item: { + id: { S: jobId }, + content: { S: payload.content }, + }, + }); + return jobId; + }).pipe(Effect.orDie), + ), + ); + + return { + fetch: yield* HttpRouter.toHttpEffect( + HttpApiBuilder.layer(JobApi).pipe(Layer.provide(jobsGroup)), + ), + }; + }).pipe( + Effect.provide( + Layer.mergeAll( + AWS.DynamoDB.GetItemHttp, + AWS.DynamoDB.PutItemHttp, + HttpPlatform.layer, + Etag.layer, + ), + ), + ), +) {} +``` + +## 4. Add to the Stack + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; +import JobFunction from "./src/JobFunction.ts"; + +export default Alchemy.Stack( + "JobApi", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { + const func = yield* JobFunction; + return { url: func.functionUrl }; + }), +); +``` + +Yielding `JobFunction` deploys the function *and* everything it +declared inside the constructor — the DynamoDB table, the generated IAM role +with the two table-scoped statements, and the Function URL. + +## 5. Deploy + +```sh +bun alchemy deploy +``` + +Hit the URL from the deploy output with `curl`: + +```sh +curl -X POST https://.lambda-url.us-west-2.on.aws/ \ + -H "Content-Type: application/json" \ + -d '{"content": "Ship the docs"}' +# → "d3adb33f-..." + +curl "https://.lambda-url.us-west-2.on.aws/?jobId=d3adb33f-..." +# → {"id":"d3adb33f-...","content":"Ship the docs"} +``` + +Invalid payloads get automatic 400 responses with structured +validation errors, and a missing job comes back as a 404 — the +`httpApiStatus` we declared on `JobNotFound`. + +## 6. Call it from a typed test + +Because `JobApi` is just a value, the same spec drives a fully typed +client — no codegen, no string URLs. Combine `HttpApiClient` with +the test harness to deploy the Stack and exercise the API +end-to-end: + +```typescript +// test/api.test.ts +import * as AWS from "alchemy/AWS"; +import * as Test from "alchemy/Test/Bun"; +import { expect } from "bun:test"; +import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; +import * as HttpApiClient from "effect/http-api/HttpApiClient"; +import Stack from "../alchemy.run.ts"; +import { JobApi } from "../src/JobApi.ts"; + +const { test, beforeAll, deploy } = Test.make({ + providers: AWS.providers(), + state: AWS.state(), +}); + +const stack = beforeAll(deploy(Stack)); + +test( + "create and fetch a job through the typed client", + Effect.gen(function* () { + const { url } = yield* stack; + const client = yield* HttpApiClient.make(JobApi, { baseUrl: url }); + + // a fresh Function URL can take a few seconds to start serving + const jobId = yield* client.Jobs.createJob({ + payload: { content: "Ship the docs" }, + }).pipe( + Effect.retry({ schedule: Schedule.exponential("500 millis"), times: 10 }), + ); + + const job = yield* client.Jobs.getJob({ query: { jobId } }); + expect(job.content).toBe("Ship the docs"); + + const missing = yield* client.Jobs.getJob({ + query: { jobId: "does-not-exist" }, + }).pipe(Effect.flip); + expect(missing._tag).toBe("JobNotFound"); + }), +); +``` + +```sh +bun test test/api.test.ts +``` + +`client.Jobs.getJob` returns +`Effect` — the `JobNotFound` +branch is a real typed value you can pattern-match on (here via +`Effect.flip`), not an HTTP status code you have to interpret. + +## Go further + +The full +[`examples/aws-lambda-httpapi`](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-lambda-httpapi) +example grows this app in a few directions: it hides DynamoDB behind +a cloud-agnostic `JobStorage` service (with a drop-in S3 +implementation), publishes job-created notifications to SNS, fans +table changes out to SQS via DynamoDB Streams, and adds a CloudWatch +dashboard and alarm. The +[Layers](/infrastructure-as-effects/layers) page +explains the service-Layer pattern it uses. + +## Where next + +- [Effect HTTP](/apis/effect-http) — the concept home: endpoints, + groups, handlers, and typed clients, independent of any host. +- [RPC](/apis) — deciding between schemaless RPC, Effect RPC, and + Effect HTTP. +- [Lambda](/aws/compute/lambda) — how the Function class, Function URLs, + and the test harness work. +- [Effect HTTP API on Workers](/cloudflare/apis/effect-http-api) — + the same `HttpApi` spec deployed to Cloudflare. +- [DynamoDB](/aws/data/dynamodb) — the table resource and the rest of + its bindings. +- [REST API (API Gateway v1)](/aws/apis/api-gateway) — put API + Gateway in front instead of a Function URL. diff --git a/.agents/skills/alchemy/references/aws/apis/effect-rpc.md b/.agents/skills/alchemy/references/aws/apis/effect-rpc.md new file mode 100644 index 00000000..98e974de --- /dev/null +++ b/.agents/skills/alchemy/references/aws/apis/effect-rpc.md @@ -0,0 +1,426 @@ + + +# Effect RPC on Lambda + +> Build a typed RPC API with Effect's Rpc module and deploy it as an AWS Lambda Function behind a Function URL. + +Effect RPC is a trust-boundary tool: schemas validate every request, +so reach for it when data needs sanitizing on its way in — a web app +or an external service calling your Lambda. Internal calls use +[Schemaless RPC](/apis/schemaless) instead (on AWS, a Lambda Function +driving a [MicroVM](/aws/compute/microvms)); the +[RPC overview](/apis) has the full decision. + +The [HTTP API guide](/aws/apis/effect-http-api) showed how to +build REST-style endpoints with schema validation. Effect RPC takes +a different angle — you define **procedures** instead of HTTP +endpoints, and you get a fully typed client for free with no URL +construction or manual serialization. +[Effect RPC](/apis/effect-rpc) covers the host-agnostic core; this +page covers the Lambda side — a public Function URL via `functionUrl: true`, +and binding implementations provided with +`Layer.mergeAll(AWS.DynamoDB.GetItemHttp, AWS.DynamoDB.PutItemHttp)`. + +The transport is still HTTP under the hood — the RPC server compiles +down to the same `HttpEffect` a Lambda's `fetch` expects — so the +wiring story is identical to the HTTP API guide: + +1. **Define schemas outside the Function.** Domain types and tagged + errors, importable by both server and client. +2. **Bind resources and construct handlers inside the Function's + Construction phase.** `RpcGroup.toLayer` is pure construction — safe at + deploy time and at cold start. +3. **Return `{ fetch }`** where `fetch` is the `HttpEffect` produced + by `RpcServer.toHttpEffect`. +4. **Deploy** and call the procedures from a typed client that + shares the exact same `RpcGroup` value. + +A complete, runnable version of this app lives at +[`examples/aws-lambda-rpc`](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-lambda-rpc). + +## 1. Define the schemas + +Domain model and error types — pure schemas, no runtime concerns: + +```typescript +// src/Job.ts +import * as Schema from "effect/Schema"; + +export type JobId = Schema.Schema.Type; +export const JobId = Schema.String.annotate({ + description: "The ID of the job", +}); + +export class Job extends Schema.Class("Job")({ + id: JobId, + content: Schema.String, +}) {} + +export class JobNotFound extends Schema.TaggedClass()( + "JobNotFound", + { jobId: JobId }, +) {} + +export class PutJobFailed extends Schema.TaggedClass()( + "PutJobFailed", + { message: Schema.String }, +) {} +``` + +RPC errors are schema-backed tagged classes. The client receives +them as typed values you can `catchTag` on — not raw HTTP status +codes, so there's no `httpApiStatus` annotation here. + +## 2. Declare the RPCs + +Each `Rpc.make` declares one procedure: a name, a payload schema, a +success schema, and an error schema. `RpcGroup.make` collects them +into a single value that both the server and the client will share. + +```typescript +// src/JobRpcs.ts +import * as Schema from "effect/Schema"; +import { Rpc, RpcGroup } from "effect/rpc"; +import { Job, JobId, JobNotFound, PutJobFailed } from "./Job.ts"; + +const getJob = Rpc.make("getJob", { + success: Job, + error: JobNotFound, + payload: { + jobId: JobId, + }, +}); + +const createJob = Rpc.make("createJob", { + success: JobId, + error: PutJobFailed, + payload: { + content: Schema.String, + }, +}); + +export class JobRpcs extends RpcGroup.make(getJob, createJob) {} +``` + +`JobRpcs` is just a value-level description. Nothing executes yet. + +## 3. Build the Function + +Create `src/JobFunction.ts` with an empty Construction phase and a public +Function URL: + +```typescript +// src/JobFunction.ts +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; + +export default class JobFunction extends AWS.Lambda.Function()( + "JobFunction", + { main: import.meta.url, functionUrl: true }, + Effect.gen(function* () { + return {}; + }), +) {} +``` + +The generator is the **Construction phase**. It runs at *deploy time* (when +Alchemy plans the stack and collects bindings into IAM policies and +environment variables) and again *inside the deployed Lambda* at +cold start. Only do pure construction here — bindings, layers, never +per-request work. + +### 3a. Declare the table and bind operations + +Jobs need somewhere durable to live. Yield a DynamoDB `Table` inside +the constructor to declare the resource, then bind the two operations the +handlers will need: + +```diff lang="typescript" + Effect.gen(function* () { ++ const table = yield* AWS.DynamoDB.Table("JobsTable", { ++ partitionKey: "id", ++ attributes: { id: "S" }, ++ }); ++ ++ const getItem = yield* AWS.DynamoDB.GetItem(table); ++ const putItem = yield* AWS.DynamoDB.PutItem(table); + + return {}; + }), +``` + +Each binding does double duty: at deploy time it attaches a +least-privilege IAM statement to the function's execution role; at +runtime it returns a typed callable that injects the table name +automatically. + +### 3b. Construct the handlers inside the constructor + +`JobRpcs.toLayer` takes one handler per procedure and produces a +`Layer`. Like `HttpApiBuilder.group`, this is pure construction — it +builds a value, it doesn't run the server. The handlers close over +the `getItem` / `putItem` bindings from step 3a: + +```diff lang="typescript" + const getItem = yield* AWS.DynamoDB.GetItem(table); + const putItem = yield* AWS.DynamoDB.PutItem(table); + ++ const handlersLayer = JobRpcs.toLayer({ ++ getJob: ({ jobId }) => ++ getItem({ Key: { id: { S: jobId } } }).pipe( ++ Effect.orDie, ++ Effect.flatMap(({ Item }) => ++ Item ++ ? Effect.succeed( ++ new Job({ ++ id: Item.id?.S ?? jobId, ++ content: Item.content?.S ?? "", ++ }), ++ ) ++ : Effect.fail(new JobNotFound({ jobId })), ++ ), ++ ), ++ createJob: ({ content }) => ++ Effect.gen(function* () { ++ const jobId = crypto.randomUUID(); ++ yield* putItem({ ++ Item: { ++ id: { S: jobId }, ++ content: { S: content }, ++ }, ++ }); ++ return jobId; ++ }).pipe( ++ Effect.mapError( ++ (error) => new PutJobFailed({ message: error.message }), ++ ), ++ ), ++ }); + + return {}; +``` + +Each handler receives the typed payload and returns an `Effect` that +either succeeds with the declared success schema or fails with the +declared error schema. `getJob` uses `Effect.orDie` to turn +unexpected DynamoDB failures into 500s — `JobNotFound` is the only +client-visible error. `createJob` maps DynamoDB failures into the +declared `PutJobFailed` error so the client can match on it. + +### 3c. Return `{ fetch }` + +`RpcServer.toHttpEffect` converts the `RpcGroup` into the +`HttpEffect` that `fetch` expects. We provide two layers to it: the +`handlersLayer` we just built and `RpcSerialization.layerJson`, +which tells the server to encode and decode messages as JSON (swap +for `layerNdjson` when you add streaming procedures). Unlike the +HTTP API approach, RPC doesn't need `HttpPlatform.layer` or +`Etag.layer` — the RPC server handles message framing internally. + +```diff lang="typescript" + return { +- }; ++ fetch: yield* RpcServer.toHttpEffect(JobRpcs).pipe( ++ Effect.provide( ++ Layer.mergeAll(handlersLayer, RpcSerialization.layerJson), ++ ), ++ ), ++ }; + }).pipe( ++ Effect.provide( ++ Layer.mergeAll( ++ AWS.DynamoDB.GetItemHttp, ++ AWS.DynamoDB.PutItemHttp, ++ ), ++ ), + ), +``` + +The outer `Effect.provide` supplies the binding implementations — +`GetItemHttp` / `PutItemHttp` implement the contracts over +DynamoDB's HTTP API, signed with the Lambda's execution role. + +### Putting it together + +```typescript +// src/JobFunction.ts +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { RpcSerialization, RpcServer } from "effect/rpc"; +import { Job, JobNotFound, PutJobFailed } from "./Job.ts"; +import { JobRpcs } from "./JobRpcs.ts"; + +export default class JobFunction extends AWS.Lambda.Function()( + "JobFunction", + { main: import.meta.url, functionUrl: true }, + Effect.gen(function* () { + const table = yield* AWS.DynamoDB.Table("JobsTable", { + partitionKey: "id", + attributes: { id: "S" }, + }); + + const getItem = yield* AWS.DynamoDB.GetItem(table); + const putItem = yield* AWS.DynamoDB.PutItem(table); + + const handlersLayer = JobRpcs.toLayer({ + getJob: ({ jobId }) => + getItem({ Key: { id: { S: jobId } } }).pipe( + Effect.orDie, + Effect.flatMap(({ Item }) => + Item + ? Effect.succeed( + new Job({ + id: Item.id?.S ?? jobId, + content: Item.content?.S ?? "", + }), + ) + : Effect.fail(new JobNotFound({ jobId })), + ), + ), + createJob: ({ content }) => + Effect.gen(function* () { + const jobId = crypto.randomUUID(); + yield* putItem({ + Item: { + id: { S: jobId }, + content: { S: content }, + }, + }); + return jobId; + }).pipe( + Effect.mapError( + (error) => new PutJobFailed({ message: error.message }), + ), + ), + }); + + return { + fetch: yield* RpcServer.toHttpEffect(JobRpcs).pipe( + Effect.provide( + Layer.mergeAll(handlersLayer, RpcSerialization.layerJson), + ), + ), + }; + }).pipe( + Effect.provide( + Layer.mergeAll(AWS.DynamoDB.GetItemHttp, AWS.DynamoDB.PutItemHttp), + ), + ), +) {} +``` + +## 4. Add to the Stack + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; +import JobFunction from "./src/JobFunction.ts"; + +export default Alchemy.Stack( + "JobRpc", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { + const func = yield* JobFunction; + return { url: func.functionUrl }; + }), +); +``` + +Yielding `JobFunction` deploys the function *and* everything it +declared inside the constructor — the DynamoDB table, the generated IAM role +with the two table-scoped statements, and the Function URL. + +## 5. Deploy + +```sh +bun alchemy deploy +``` + +## 6. Call it from a typed client + +Because `JobRpcs` is just a value, the same group drives a fully +typed client — no codegen. `client.createJob` accepts +`{ content: string }` and returns `Effect`. + +```typescript +// scripts/client.ts +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import { RpcClient, RpcSerialization } from "effect/rpc"; +import { JobRpcs } from "../src/JobRpcs.ts"; + +const program = Effect.gen(function* () { + const client = yield* RpcClient.make(JobRpcs); + + const jobId = yield* client.createJob({ content: "Ship the docs" }); + console.log("Created:", jobId); + + const job = yield* client.getJob({ jobId }); + console.log("Fetched:", job.content); +}); + +Effect.runPromise( + program.pipe( + Effect.scoped, + Effect.provide( + RpcClient.layerProtocolHttp({ url: process.env.JOB_RPC_URL! }).pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(RpcSerialization.layerJson), + ), + ), + ), +); +``` + +Get the URL from the deploy output and run it: + +```sh +JOB_RPC_URL=https://.lambda-url.us-west-2.on.aws bun scripts/client.ts +``` + +The errors are typed values: `client.getJob` returns +`Effect`, and you can +`Effect.catchTag("JobNotFound", ...)` to handle the missing case +explicitly. + +## Choosing an API style + +- **External Effect/TypeScript consumers** — this page. +- **External plain-HTTP consumers** (curl, non-TypeScript clients) — + [Effect HTTP API on Lambda](/aws/apis/effect-http-api). +- **Internal service-to-service calls** — + [Schemaless RPC](/apis/schemaless). +- **The full decision** — [RPC overview](/apis). + +## Go further + +The full +[`examples/aws-lambda-rpc`](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-lambda-rpc) +example grows this app in a few directions: it hides DynamoDB behind +a cloud-agnostic `JobStorage` service (with a drop-in S3 +implementation), publishes job-created notifications to SNS, fans +table changes out to SQS via DynamoDB Streams, and adds a CloudWatch +dashboard and alarm. The +[Layers](/infrastructure-as-effects/layers) page +explains the service-Layer pattern it uses. + +## Where next + +- [Lambda](/aws/compute/lambda) — how the Function class, Function URLs, and + the test harness work. +- [Effect HTTP API on Lambda](/aws/apis/effect-http-api) — the + REST-style sibling of this guide, same wiring. +- [Effect RPC on Workers](/cloudflare/apis/effect-rpc) — the same + `RpcGroup` deployed to Cloudflare, plus streaming and Durable + Object routing. +- [DynamoDB](/aws/data/dynamodb) — the table resource and the rest of its + bindings. diff --git a/.agents/skills/alchemy/references/aws/apis/schemaless-rpc.md b/.agents/skills/alchemy/references/aws/apis/schemaless-rpc.md new file mode 100644 index 00000000..f466223d --- /dev/null +++ b/.agents/skills/alchemy/references/aws/apis/schemaless-rpc.md @@ -0,0 +1,139 @@ + + +# Schemaless RPC + +> Typed RPC from a Lambda Function into a MicroVM with no schema — declare the image's Shape, connect, get the client. + +The pattern — what a Shape member may be, how the typed client arises, what +crosses the wire — lives at [Schemaless RPC](/apis/schemaless). This page +covers the AWS pairings. There is one today: **Lambda → Firecracker MicroVM**, +riding the generic fetch transport — each call is a +`POST https:///__rpc__/{name}`. + +## The image declares its Shape + +```typescript +// sandbox.ts — the MicroVM image +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; + +export class Sandbox extends AWS.Lambda.MicrovmImage< + Sandbox, + { + hello: (message: string) => Effect.Effect; + } +>()("MicrovmSandbox") {} + +export default Sandbox.make( + { main: import.meta.filename /* buildRole, resources, cpuConfigurations */ }, + Effect.gen(function* () { + return { + fetch: Effect.gen(function* () { + return HttpServerResponse.text("hello from effectful microvm"); + }), + hello: (message: string) => Effect.succeed(`hello, ${message}!`), + }; + }), +); +``` + +Like Containers on Cloudflare, the MicroVM image declares its Shape as an +explicit type parameter — the caller reaches it over the network, so nothing +can be inferred from a binding. The constructor Effect returns `{ fetch, ...rpcs }`; +the in-VM runtime serves the RPC methods over `/__rpc__/*` and falls through +to `fetch` for everything else, so the same image answers both call surfaces. + +## Provision a VM and mint a token + +```typescript +// orchestrator.ts — the Lambda binds the instance operations to the image +const runMicrovm = yield* AWS.Lambda.RunMicrovm(Sandbox); +const getMicrovm = yield* AWS.Lambda.GetMicrovm(Sandbox); +const createAuthToken = yield* AWS.Lambda.CreateAuthToken(Sandbox); +const terminateMicrovm = yield* AWS.Lambda.TerminateMicrovm(Sandbox); + +// ...inside a handler: run, wait for RUNNING, mint a token +const vm = yield* runMicrovm({ + idlePolicy: { + maxIdleDurationSeconds: 900, + suspendedDurationSeconds: 300, + autoResumeEnabled: true, + }, +}); + +yield* getMicrovm({ microvmIdentifier: vm.microvmId }).pipe( + Effect.flatMap((m) => + m.state === "RUNNING" + ? Effect.void + : Effect.fail(new Error(`microvm ${m.state}`)), + ), + Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 30 }), +); + +const { authToken } = yield* createAuthToken({ + microvmIdentifier: vm.microvmId, + expirationInMinutes: 5, + allowedPorts: [{ port: 8080 }], +}); +``` + +Unlike a service binding, a MicroVM has a lifecycle: the Lambda binds the +instance operations (`RunMicrovm`, `GetMicrovm`, `CreateAuthToken`, +`TerminateMicrovm`), launches an instance, polls with a bounded retry until it +reaches `RUNNING`, and mints a short-lived auth token scoped to the RPC port. +Wrap the launched instance in `Effect.ensuring(terminateMicrovm(...))` so a +failing step never leaks a running VM. The full lifecycle — suspend, resume, +list, the raw `fetch` route via `microvmAuthHeaders` — is in +[MicroVMs](/aws/compute/microvms#launch-and-drive-microvms-from-a-lambda). + +## Connect and call + +```typescript +const sandbox = yield* AWS.Lambda.connectMicrovm(Sandbox, { + endpoint: vm.endpoint, + authToken, +}); +const reply = yield* sandbox.hello(message).pipe( + Effect.retry({ + schedule: Schedule.exponential("500 millis"), + times: 8, + }), +); +// reply === `hello, ${message}!` +``` + +`connectMicrovm` returns the typed stub inferred from `Sandbox` — each method +call is a `POST https:///__rpc__/` with the auth-token +entries set as request headers, the mirror of the server running inside the +VM. Retry the first call: the endpoint takes a few seconds to start serving +after the VM reports `RUNNING`. + +## What AWS doesn't have yet + +There is no schemaless stub between Lambda Functions — Lambda-to-Lambda calls +go through the `AWS.Lambda.InvokeFunction` binding (see +[Lambda](/aws/compute/lambda)) or a Function URL, and typed cross-Lambda APIs +should use [Effect RPC](/apis/effect-rpc). There is also no typed connector +for [ECS Tasks](/aws/compute/ecs) yet — a Task returns the same +`{ fetch, ...rpcs }` Shape, but with no `connectMicrovm` equivalent you reach +it over plain HTTP through its load balancer or serve Effect RPC from its +`fetch` handler. + +## When you need schemas + +Schemaless calls do no runtime validation — nothing decodes or sanitizes what +arrives. At a trust boundary, or when class identity must survive the hop, use +[Effect RPC](/apis/effect-rpc). + +## Where next + +- [Schemaless RPC](/apis/schemaless) — the pattern: allowed members, the wire + codec, streams, errors +- [MicroVMs](/aws/compute/microvms) — build images, drive instances, both call + surfaces +- [Effect RPC](/apis/effect-rpc) — schema-first RPC for trust boundaries +- [Cloudflare Schemaless RPC](/cloudflare/apis/schemaless-rpc) — the Cloudflare + pairings: Workers, Durable Objects, Containers diff --git a/.agents/skills/alchemy/references/aws/compute/choosing-a-runtime.md b/.agents/skills/alchemy/references/aws/compute/choosing-a-runtime.md new file mode 100644 index 00000000..149a41ba --- /dev/null +++ b/.agents/skills/alchemy/references/aws/compute/choosing-a-runtime.md @@ -0,0 +1,144 @@ + + +# Choosing a runtime + +> Lambda vs ECS vs EKS vs EC2 for Alchemy apps — cold starts, cost shape, packaging, and how much of each Alchemy currently covers. + +Every Alchemy app on AWS needs somewhere for its code to run. +Alchemy ships four runtimes, all driven by the same pattern — +bundle an Effect program, deploy it as a resource, bind building +blocks to it: + +- **Lambda** — serverless functions. **Use this by default.** +- **ECS** — long-running containers on Fargate. +- **EKS** — containers as Kubernetes workloads on a managed + Auto Mode cluster. +- **EC2** — virtual machines you fully control. + +## At a glance + +| | Lambda | ECS | EKS | EC2 | +| ---------------- | ------------------------------------------ | ------------------------------------------ | -------------------------------------------- | ---------------------------------------- | +| Execution model | Per-request, event-driven | Always-on containers | Kubernetes objects (Deployments, Jobs) | Always-on machine | +| Cost shape | Pay per invocation; scales to zero | Pay per running task | Control plane + Auto Mode nodes | Pay per running instance | +| Startup | Cold starts (ms–s), then warm | Task launch (tens of seconds), then steady | Cluster create (~10 min), Pods in seconds | Instance boot (minutes), then steady | +| Packaging | Bundled zip (Rolldown) | Docker image, built + pushed for you | Docker image, built + pushed for you | Bundled program on an AMI you choose | +| Networking | None required (Function URL) | VPC required (awsvpc), optional ALB | VPC required, `LoadBalancer` Services | VPC required, you own every primitive | +| Alchemy coverage | Fully documented | Cluster/Service/Task resources | Cluster/Deployment/Job/Manifest/Helm | Instance + full VPC primitives | + +## Lambda — the default + +Serverless, event-driven, and the runtime this documentation is +written around. You get the [`Function`](/providers/aws/lambda/function) +resource with a public Function URL, typed bindings that mint +least-privilege IAM policies from your call sites, and +`Stream`-shaped event sources for SQS, Kinesis, DynamoDB +Streams, S3 notifications, SNS, and EventBridge. Cold starts and +the 15-minute execution cap are the trade; per-request pricing +and zero idle cost are the payoff. + +Choose Lambda unless you have a specific reason not to. + +→ [Lambda](/aws/compute/lambda) + +## ECS — long-running containers + +When your workload doesn't fit a request/response window — a +WebSocket server, a worker that holds connections open, anything +that should be always-on — run it as a container. Alchemy models +ECS with three resources: + +- [`Cluster`](/providers/aws/ecs/cluster) — an ECS cluster for + running tasks and services. +- [`Task`](/providers/aws/ecs/task) — bundles an inline Effect + program, builds and pushes a Docker image to a generated ECR + repository, provisions task + execution IAM roles and a + CloudWatch log group, and registers a Fargate task definition. + Tasks can serve HTTP directly and accept the same + binding contract (env + IAM policy statements) as Lambda. +- [`Service`](/providers/aws/ecs/service) — keeps a task + definition running with awsvpc networking; set `public: true` + and Alchemy provisions a public ALB + listener + target group + for you. + +You pay for running tasks whether or not they're serving +traffic, and you bring a VPC (subnets + security groups) — the +[`Network`](/providers/aws/ec2/network) helper builds one in a +single call. + +→ [ECS](/aws/compute/ecs) + +## EKS — when your platform is Kubernetes + +When the workload is container-shaped but you want Kubernetes +itself — Helm charts, operators, raw manifests, or teams already +speaking Deployments and Jobs — Alchemy targets **EKS Auto Mode**, +where AWS manages the control plane, nodes, storage, and +load-balancer integration. Alchemy models it with: + +- [`Cluster`](/providers/aws/eks/cluster) — the control plane; + `compute: "auto"` turns on Auto Mode with sensible defaults. +- [`Deployment`](/providers/kubernetes/deployment) — a replicated + Kubernetes server (the Kubernetes analog of `AWS.ECS.Service`) + with the same three image sources as ECS: a registry `image`, a + Dockerfile `context`, or an inline Effect program via `main`. +- [`Job`](/providers/kubernetes/job) — run-to-completion work, or a + `CronJob` when you set `schedule`. +- [`Manifest`](/providers/kubernetes/manifest) and + [`HelmChart`](/providers/kubernetes/helmchart) — any raw Kubernetes + object or a rendered Helm chart, applied via server-side apply. + +Bindings work the same as on Lambda and ECS — env vars plus IAM +policy statements, delivered through an EKS Pod Identity role — +and there is no YAML and no `kubectl` step. You pay for the +control plane and the nodes Auto Mode provisions, you bring a VPC +(the [`Network`](/providers/aws/ec2/network) helper builds one), +and the control plane takes ~10 minutes to create. + +→ [EKS](/aws/compute/eks) + +For ML training fleets specifically — persistent, health-checked +GPU clusters rather than application containers — EKS pairs with +[SageMaker HyperPod](/aws/compute/hyperpod): the HyperPod nodes +join your EKS cluster, and `Deployment`/`Job` opt onto them with +the `hyperpod` prop. + +## EC2 — when you need the machine + +Full-control VMs for workloads that need an OS, a GPU, custom +daemons, or just predictable dedicated capacity. The +[`Instance`](/providers/aws/ec2/instance) resource can act as a +low-level compute primitive or run a bundled long-lived Effect +program directly on the machine (including serving HTTP), and +Alchemy ships the complete networking toolkit around it: +[`Vpc`](/providers/aws/ec2/vpc), +[`Subnet`](/providers/aws/ec2/subnet), +[`SecurityGroup`](/providers/aws/ec2/securitygroup), NAT and +internet gateways, route tables, EIPs, and VPC endpoints (see +[VPC & networking](/aws/networking)). + +You own patching, scaling, and availability. + +→ [EC2](/aws/compute/ec2) + +## Rule of thumb + +1. Start with **Lambda**. Per-request cost, no servers, and the + whole documented binding/event-source surface. +2. Move a workload to **ECS** when it's long-running, needs more + than 15 minutes, or is already a container. +3. Pick **EKS** over ECS when you want Kubernetes itself — + Helm charts, operators, raw manifests — not just a container + runner. +4. Reach for **EC2** when you need the machine itself — kernel + access, GPUs, custom networking, or software that expects a + host. + +## Where next + +- [Lambda](/aws/compute/lambda) — deploy a function with a public URL. +- [AWS overview](/aws) — resources and recipes. +- [Providers reference](/providers) — generated docs for every + ECS, EKS, and EC2 resource. diff --git a/.agents/skills/alchemy/references/aws/compute/ec2.md b/.agents/skills/alchemy/references/aws/compute/ec2.md new file mode 100644 index 00000000..0bf34115 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/compute/ec2.md @@ -0,0 +1,172 @@ + + +# EC2 + +> Launch virtual machines with the Instance resource — as a raw compute primitive, or hosting a bundled long-lived Effect program served straight off the box. + +**EC2** is for when you need the machine itself: an OS you +control, GPUs, custom daemons, software that expects a host, or +predictable dedicated capacity. The +[`Instance`](/providers/aws/ec2/instance) resource plays two +roles — a **low-level compute primitive** (launch an AMI, attach +networking, done), or a **host for a bundled Effect program** +that Alchemy deploys onto the machine and keeps running. + +This is the lowest-level of Alchemy's four runtimes: unlike +[Lambda](/aws/compute/lambda), [ECS](/aws/compute/ecs), and +[EKS](/aws/compute/eks), you own patching, +scaling, and availability. Reach for it deliberately — see +[Choosing a runtime](/aws/compute/choosing-a-runtime). + +## Launch an instance + +The minimal instance is an AMI plus an instance type. AMI IDs +are per-region, so resolve one with Alchemy's image helpers +instead of hard-coding — `amazonLinux()` finds the latest Amazon +Linux AMI (there are also `amazonLinux2023`, `amazonLinux2`, +`ubuntu2404`, `ubuntu2204`, an id-only `image()` finder, and the +underlying `getAmi()` data source returning the full image +description). Each returns an `Output` resolved at deploy time: + +```typescript +const instance = yield* AWS.EC2.Instance("AppInstance", { + imageId: AWS.EC2.amazonLinux(), + instanceType: "t3.micro", + subnetId: subnet.subnetId, +}); +``` + +The resolved instance exposes `instanceId`, `publicIpAddress`, +`privateIpAddress`, DNS names, and the rest of the observed +state — see the [`Instance` reference](/providers/aws/ec2/instance) +for every prop and attribute. + +## Host an Effect program + +Give the instance a `main` entrypoint and it becomes a runtime, +same class pattern as Lambda and ECS. Because instance props +(the network, the AMI) are resolved by the stack, the props slot +takes an `Effect.gen` block. The composition is also re-executed +inside the deployed instance's bundle — that's safe as-is: +resource yields resolve to references at runtime, and the AMI +helper returns an `Output` that only resolves at deploy time: + +```typescript +// src/server.ts +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; + +export default class Server extends AWS.EC2.Instance()( + "Server", + Effect.gen(function* () { + const network = yield* AWS.EC2.Network("AppNetwork", { + cidrBlock: "10.81.0.0/16", + availabilityZones: 1, + }); + const securityGroup = yield* AWS.EC2.SecurityGroup("AppSg", { + vpcId: network.vpcId, + description: "app ingress", + ingress: [ + { + ipProtocol: "tcp", + fromPort: 3000, + toPort: 3000, + cidrIpv4: "0.0.0.0/0", + description: "app", + }, + ], + egress: [ + { + ipProtocol: "-1", + cidrIpv4: "0.0.0.0/0", + description: "all outbound", + }, + ], + }); + + return { + main: import.meta.url, + imageId: AWS.EC2.amazonLinux(), + instanceType: "t3.small", + subnetId: network.publicSubnetIds[0], + securityGroupIds: [securityGroup.groupId], + associatePublicIpAddress: true, + port: 3000, + }; + }), + Effect.gen(function* () { + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const url = new URL(request.url, "http://instance"); + if (url.pathname === "/health") { + return yield* HttpServerResponse.json({ ok: true }); + } + return HttpServerResponse.text("hello from EC2"); + }), + }; + }), +) {} +``` + +At deploy time Alchemy bundles the program, uploads it to S3, +and boots the instance with a user-data script that installs +Bun and writes a **systemd unit**. The unit syncs the bundle and +environment from S3 on every start and runs with +`Restart=always`, so a flaky network install self-heals and the +service survives reboots. The `{ fetch }` handler is served by +the instance's HTTP server on `port`; `ServerHost` + `host.run` +background loops work exactly as they do on [ECS](/aws/compute/ecs). + +Hosted instances also get an Alchemy-managed IAM role and +instance profile. Bindings use the same contract as Lambda — +environment variables plus IAM policy statements attached to +that role — and you can widen it with managed policies: + +```typescript +// e.g. make the instance manageable via SSM Session Manager +roleManagedPolicyArns: [ + "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore", +], +``` + +## SSH access + +For direct access, create an Alchemy-managed +[`KeyPair`](/providers/aws/ec2/keypair) and pass its name to the +instance. The generated private key comes back as a `Redacted` +output on the resource: + +```typescript +const key = yield* AWS.EC2.KeyPair("AppKeyPair", { + keyType: "ed25519", +}); + +// then on the instance props: +// keyName: key.keyName +``` + +## Networking + +An instance is only as reachable as the network you launch it +into. The example above leans on the +[`Network`](/providers/aws/ec2/network) helper for a +public-subnet VPC; when you need explicit control — private +subnets, NAT, VPC endpoints, custom route tables — compose the +primitives directly. [VPC & networking](/aws/networking) walks +through the whole set. + +## Where next + +- [Choosing a runtime](/aws/compute/choosing-a-runtime) — Lambda first, + ECS for containers, EKS for Kubernetes, EC2 when you need the + machine. +- [VPC & networking](/aws/networking) — the `Network` helper and + the VPC primitives an instance lives in. +- [`Instance` reference](/providers/aws/ec2/instance), + [`KeyPair` reference](/providers/aws/ec2/keypair) — every prop + and attribute. diff --git a/.agents/skills/alchemy/references/aws/compute/ecs.md b/.agents/skills/alchemy/references/aws/compute/ecs.md new file mode 100644 index 00000000..c13e387a --- /dev/null +++ b/.agents/skills/alchemy/references/aws/compute/ecs.md @@ -0,0 +1,319 @@ + + +# ECS + +> Run containers on AWS with ECS and Fargate — Task definitions that run to completion, Services that keep containers running behind a load balancer, with images bundled from an Effect program, built from your Dockerfile, or mirrored from a registry. + +**ECS** (Elastic Container Service) is AWS's managed container +orchestrator: you describe a container — image, CPU, memory — and +ECS runs it. With **Fargate**, AWS also provides the machines, so +there are no servers to manage. + +ECS has four primitives: + +- A **Cluster** is the space your containers run in. +- A **Task Definition** is the blueprint: which image, how much + CPU and memory, which IAM roles, which ports. +- A **Task** is a running container launched from a Task + Definition. It runs until its process exits. +- A **Service** keeps a set number of Tasks running — restarting + ones that stop, optionally routing traffic to them through a + load balancer. + +Alchemy models these directly: +[`Cluster`](/providers/aws/ecs/cluster), +[`Task`](/providers/aws/ecs/task) — a task definition plus +everything needed to build and publish its image — and +[`Service`](/providers/aws/ecs/service). Each can run a plain +container image, or an Effect program that Alchemy bundles into +one. + +## Run a Task + +The simplest `Task` runs a pre-built registry image — no Effect +runtime in the container: + +```typescript +// alchemy.run.ts +import * as AWS from "alchemy/AWS"; + +const migrate = yield* AWS.ECS.Task("DbMigrate", { + image: "public.ecr.aws/docker/library/busybox:stable", + command: ["sh", "-c", "echo done"], + cpu: 256, + memory: 512, +}); +``` + +Deploying a `Task` automates the container supply chain: +Alchemy provisions the **task and execution IAM roles**, a +**CloudWatch log group**, and a **generated ECR repository** +holding the image, then registers a Fargate task definition. Each +deploy registers a new immutable revision. + +## Three image sources + +The image comes from exactly one of three sources, flat on the +props: + +- `image` — run a pre-built registry reference, mirrored into ECR + (pull → tag → push, content-addressed). +- `context` — build your own Dockerfile with your + [local Docker](/docker/setup). `dockerfile` is always a **path**, + defaulting to `${context}/Dockerfile`: + + ```typescript + const render = yield* AWS.ECS.Task("RenderJob", { + context: "./render", + dockerfile: "./render/Dockerfile.gpu", + cpu: 1024, + memory: 4096, + }); + ``` + +- `main` — bundle an inline Effect program into a generated image. + Compose with `image` to pick the environment base (default + `oven/bun:1`), or with an inline `dockerfile` + (`Dockerfile.inline`) when the environment needs extra build + steps. + +:::tip +`runtimePlatform.cpuArchitecture` picks the architecture the task +runs on in Fargate (default `X86_64`). Alchemy builds or mirrors +the image for whatever the task definition declares, regardless of +the machine you build on. Declaring `"ARM64"` runs the task on +Graviton — cheaper per vCPU, and image builds happen to be native +(no emulation) on Apple Silicon machines. +::: + +## Run an Effect program in a Task + +Pass `main: import.meta.url` and a constructor Effect whose impl +returns `{ run }` — the program runs to completion when the +container starts, then the container exits. Bindings work exactly +as on Lambda, attaching environment variables and IAM policy +statements to the task: + +```typescript +const drainer = yield* AWS.ECS.Task( + "QueueDrainer", + { main: import.meta.url, image: "oven/bun:1", cpu: 256, memory: 512 }, + Effect.gen(function* () { + const receive = yield* AWS.SQS.ReceiveMessage(queue); + return { + run: Effect.gen(function* () { + // runs to completion, then the container exits + const batch = yield* receive({ MaxNumberOfMessages: 10 }); + }), + }; + }), +); +``` + +The tagged form +(`class Reindexer extends AWS.ECS.Task()("Reindexer") {}` ++ `Reindexer.make(props, impl)`) works exactly as it does on +Lambda and Cloudflare Workers. + +## Invoke and schedule Tasks + +A `Task` is the target of the ECS control-plane bindings. From a +Lambda function, a Service, or any other host, bind +[`RunTask`](/providers/aws/ecs/runtask) in the **Construction phase** — +this grants the host `ecs:RunTask` plus `iam:PassRole` on the +task's roles — then call it from a handler at **runtime**, where +the cluster and task definition ARNs are injected automatically: + +```typescript +const api = yield* AWS.Lambda.Function( + "Api", + { main: import.meta.url, functionUrl: true }, + Effect.gen(function* () { + // Construction: bind the launch (IAM grants happen here) + const runTask = yield* AWS.ECS.RunTask(cluster, task); + + return { + fetch: Effect.gen(function* () { + // runtime: launch a task per request + const response = yield* runTask({ + launchType: "FARGATE", + networkConfiguration: { + awsvpcConfiguration: { subnets: [subnetId] }, + }, + }); + return yield* HttpServerResponse.json({ + taskArn: response.tasks?.[0]?.taskArn, + }); + }), + }; + }), +); +``` + +For cron-style execution, `AWS.ECS.every` provisions an +EventBridge rule (plus the invoke role) that runs the task on a +schedule — plain-English durations normalize to `rate(...)`, and +`cron(...)` expressions pass through as-is: + +```typescript +yield* AWS.ECS.every("NightlyJob", "cron(0 3 * * ? *)", { + cluster, + task: nightlyTask, + subnets: [privateSubnet1.subnetId, privateSubnet2.subnetId], + securityGroups: [jobSecurityGroup.groupId], +}); +``` + +## Keep a server running with a Service + +A `Service` keeps `desiredCount` copies of a container alive. +It takes the same three image sources as `Task` — synthesizing +its own task definition — and `loadBalancer: true` provisions a +public **Application Load Balancer**, target group, and listener +in front of it: + +```typescript +const cluster = yield* AWS.ECS.Cluster("AppCluster", {}); + +const nginx = yield* AWS.ECS.Service("Edge", { + cluster, + image: "public.ecr.aws/nginx/nginx:1.27", + port: 80, + desiredCount: 2, + loadBalancer: true, +}); + +return { url: nginx.url }; // http:// +``` + +Networking is optional to start: when `vpcId`/`subnets` are +omitted the account's **default VPC** (and its per-AZ subnets) is +used, and when `securityGroups` is omitted with +`loadBalancer: true`, Alchemy provisions a security group that +admits the listener port. For a real deployment, build a +dedicated VPC with the [`Network`](/providers/aws/ec2/network) +helper and pass `vpcId` + `subnets` — see +[VPC & networking](/aws/networking). + +An effectful `Service` is the server counterpart: where a `Task` +impl returns `{ run }`, a `Service` impl returns `{ fetch }`: + +```typescript +const api = yield* AWS.ECS.Service( + "Api", + { cluster, main: import.meta.url, port: 3000, desiredCount: 2, cpu: 256, memory: 512 }, + Effect.gen(function* () { + const putItem = yield* AWS.DynamoDB.PutItem(table); + return { + fetch: Effect.gen(function* () { + return yield* HttpServerResponse.json({ ok: true }); + }), + }; + }).pipe(Effect.provide(AWS.DynamoDB.PutItemHttp)), +); +``` + +## Reuse a Task's definition + +A `Service` can also deploy an existing `Task`'s definition +instead of owning an image — shared image, roles, and config; the +Service adds `desiredCount`, load balancing, and deployment +configuration: + +```typescript +const api = yield* AWS.ECS.Service("Api", { + cluster, + task: apiTask, + desiredCount: 2, + loadBalancer: true, +}); +``` + +Most service configuration — desired count, task definition +revision, network config, deployment settings, load balancers — +updates **in place** as a rolling deployment; only truly +immutable aspects (service name, cluster, scheduling strategy, +deployment controller type, switching between `launchType` and +`capacityProviderStrategy`) replace the service. For +cost-sensitive workers, swap `launchType` (default `"FARGATE"`) +for a `capacityProviderStrategy` mixing `FARGATE_SPOT` and +`FARGATE` — see the +[`Service` reference](/providers/aws/ecs/service) for the +placement, deployment, and Service Connect knobs. + +## Run background work + +Containers are always-on, so an effectful container can do more +than answer requests. Yield `ServerHost` and register +long-running loops with `host.run` — they execute alongside the +HTTP handler for the life of the container: + +```diff lang="typescript" +import * as AWS from "alchemy/AWS"; ++import { ServerHost } from "alchemy/Server"; +import * as Effect from "effect/Effect"; ++import * as Schedule from "effect/Schedule"; + + Effect.gen(function* () { ++ const host = yield* ServerHost; ++ ++ yield* host.run( ++ Effect.log("heartbeat").pipe( ++ Effect.repeat(Schedule.spaced("30 seconds")), ++ Effect.asVoid, ++ ), ++ ); ++ + return { + fetch: Effect.gen(function* () { + // ... + }), + }; + }), +``` + +Use this for polling loops, queue drainers, or connections that +stay open across requests. + +## Process scope vs request scope + +An ECS container is a **real process**, and its instance scope +reflects that: the bundled program runs under a root scope that +closes when the process shuts down gracefully, so resources +acquired in the constructor — the connection a `host.run` loop holds open, a +warm pool shared across requests — are genuinely released on exit. +Serverless runtimes only approximate this: workerd never closes +its instance scope at all, and Lambda gets a best-effort 500 ms +SIGTERM window; a server gets a real graceful shutdown (a hard +kill still skips finalizers, as in any process). + +Each HTTP request still gets its own request `Scope`, released when +the response settles — the same per-event contract as every other +runtime. See +[Instance scope vs request scope](/infrastructure-as-effects/runtime#instance-scope-vs-request-scope) +for the model across all runtimes. + +## Bindings + +`Task` and `Service` declare the same binding contract as a +Lambda `Function`: bindings attach **environment variables and +IAM policy statements**, which Alchemy folds into the container +environment and the task role. The ECS control-plane bindings +(`RunTask`, `StopTask`, `ListTasks`, `DescribeTasks`) work from +Lambda functions and from other containers — useful for a +function that fans work out to containers. + +## Where next + +- [Choosing a runtime](/aws/compute/choosing-a-runtime) — when Lambda, + EKS, or EC2 fits better than ECS. +- [VPC & networking](/aws/networking) — what the `Network` + helper creates, and the primitives underneath it. +- [EKS](/aws/compute/eks) — the same platform model on managed + Kubernetes. +- [`Task` reference](/providers/aws/ecs/task), + [`Service` reference](/providers/aws/ecs/service), + [`Cluster` reference](/providers/aws/ecs/cluster) — every prop + and attribute. diff --git a/.agents/skills/alchemy/references/aws/compute/eks.md b/.agents/skills/alchemy/references/aws/compute/eks.md new file mode 100644 index 00000000..00162e26 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/compute/eks.md @@ -0,0 +1,446 @@ + + +# EKS + +> Stand up an EKS Auto Mode cluster on a Network and run containers on it — Deployments for servers, Jobs for run-to-completion work, Manifests for everything else. No YAML, no kubectl. + +**EKS** (Elastic Kubernetes Service) is AWS's managed Kubernetes: +AWS runs the Kubernetes control plane, and your containers run on +it as Kubernetes objects. Alchemy targets **Auto Mode**, where AWS +also manages the nodes, storage, and load-balancer integration — +no machines to operate. + +The Kubernetes primitives you'll meet here: + +- A **Cluster** is one Kubernetes installation: the API server + plus the Nodes that run your containers. +- A **Pod** is one or more containers running together — the unit + Kubernetes schedules onto Nodes. +- A **Deployment** keeps a set number of identical Pods running, + replacing ones that fail. A **Service** gives them one stable + address — and, as type `LoadBalancer`, a public one. +- A **Job** runs a Pod to completion; a **CronJob** does that on a + schedule. +- Everything in Kubernetes is an object described by a + **manifest** you apply to the Cluster. + +Alchemy models these directly: +[`Cluster`](/providers/aws/eks/cluster) with `compute: "auto"` +stands up the control plane from a VPC, and the cluster-agnostic +`alchemy/Kubernetes` workloads target it by passing the cluster +resource as their `cluster` prop: +[`Kubernetes.Deployment`](/providers/kubernetes/deployment) +synthesizes a Kubernetes `Deployment` + `Service`; +[`Kubernetes.Job`](/providers/kubernetes/job) a `Job` or `CronJob`; +and [`Kubernetes.Manifest`](/providers/kubernetes/manifest) applies +any raw Kubernetes object. The workloads live in the same TypeScript +program as the cluster, with no YAML and no `kubectl apply` step — +and the same workloads run on any other cluster your kubeconfig can +reach (`Kubernetes.KubeConfig(...)`). + +The workload providers ship in `Kubernetes.providers()` — compose it +with `AWS.providers()` in the Stack: + +```typescript +import * as AWS from "alchemy/AWS"; +import * as Kubernetes from "alchemy/Kubernetes"; +import * as Layer from "effect/Layer"; + +export default Alchemy.Stack( + "my-app", + { + providers: Layer.mergeAll(AWS.providers(), Kubernetes.providers()), + state: Alchemy.localState(), + }, + // ... +); +``` + +## Create an Auto Mode cluster + +`compute: "auto"` turns on EKS Auto Mode with sensible defaults — +managed compute (`system` and `general-purpose` node pools), block +storage, elastic load balancing, and `API` authentication — and, +when you don't pass `roleArn`, provisions and owns the cluster and +node IAM roles with the standard Auto Mode managed policies: + +```typescript +// alchemy.run.ts +import * as AWS from "alchemy/AWS"; + +const network = yield* AWS.EC2.Network("Network", { + cidrBlock: "10.42.0.0/16", + availabilityZones: 2, + nat: "single", +}); + +const cluster = yield* AWS.EKS.Cluster("Cluster", { + compute: "auto", + resourcesVpcConfig: { + subnetIds: network.privateSubnetIds, + endpointPublicAccess: true, + endpointPrivateAccess: true, + }, +}); +``` + +The cluster lands in the network's private subnets, built on a +[`Network`](/providers/aws/ec2/network). Budget for the create: an +EKS control plane takes ~10 minutes to provision. + +## Grant access and install add-ons + +Auto Mode provisions with `authenticationMode: "API"`, so cluster +access is granted through +[`AccessEntry`](/providers/aws/eks/accessentry) resources rather than +the `aws-auth` ConfigMap, and +[`Addon`](/providers/aws/eks/addon) installs EKS add-ons (EKS picks +the default compatible version when you don't pin one): + +```typescript +const admin = yield* AWS.EKS.AccessEntry("ClusterAdmin", { + clusterName: cluster.clusterName, + principalArn: "arn:aws:iam::123456789012:role/YourAdminRole", + accessPolicies: [ + { + policyArn: + "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy", + accessScope: { type: "cluster" }, + }, + ], +}); + +const metricsServer = yield* AWS.EKS.Addon("MetricsServer", { + clusterName: cluster.clusterName, + addonName: "metrics-server", +}); +``` + +The deploying principal is bootstrapped as cluster admin +(`bootstrapClusterCreatorAdminPermissions: true` is part of the Auto +Mode defaults), so these are for everyone (and everything) else that +needs in. + +## Deploy a server + +[`Kubernetes.Deployment`](/providers/kubernetes/deployment) is a +replicated Kubernetes server — the Kubernetes analog of +`AWS.ECS.Service`. It +synthesizes a Kubernetes `Deployment` + `Service` (+ +`ServiceAccount`) and applies them via server-side apply, with the +container image coming from exactly one of three sources flat on +props: `image` (a registry reference, mirrored into ECR), `context` +(build your own Dockerfile), or `main` (bundle an inline Effect +program). The simplest form runs a remote image with no Effect +runtime in the container: + +```typescript +const echo = yield* Kubernetes.Deployment("EchoServer", { + cluster, + image: "registry.k8s.io/echoserver:1.10", + namespace: "default", + replicas: 2, + port: 8080, + serviceType: "LoadBalancer", +}); + +echo.url; // LoadBalancer hostname (an NLB on Auto Mode) +echo.deploymentName; // K8s-native attrs: deploymentName, serviceName, ... +``` + +`serviceType: "LoadBalancer"` provisions a cloud load balancer and +exposes its hostname as `url`. Swap `image` for +`context: "./legacy"` to build your own Dockerfile (`dockerfile` is +a path, defaulting to `${context}/Dockerfile`). + +## Effect servers with bindings + +Pass `main: import.meta.url` and a constructor Effect and the program is +bundled into a generated image instead — the same authoring model as +[Lambda](/aws/compute/lambda). Bindings work identically too: +`Deployment` accepts the same `{ env, policyStatements }` binding +contract as a Lambda `Function`, so every AWS `Binding.Service` +attaches environment variables to the Pod spec and IAM policy +statements to a generated **Pod Identity role**: + +```typescript +const api = yield* Kubernetes.Deployment( + "Api", + { cluster, main: import.meta.url, port: 3000, replicas: 2 }, + Effect.gen(function* () { + const putItem = yield* AWS.DynamoDB.PutItem(table); + return { + fetch: Effect.gen(function* () { + yield* putItem({ Item: { id: { S: "1" } } }); + return HttpServerResponse.text("ok"); + }), + }; + }).pipe(Effect.provide(AWS.DynamoDB.PutItemHttp)), +); +``` + +Every Kubernetes workload on EKS gets Pod Identity as standard: +Alchemy creates +the IAM role, wires it to the workload's ServiceAccount with a +[`PodIdentityAssociation`](/providers/aws/eks/podidentityassociation), +and Pods resolve credentials through the EKS Pod Identity +container-credentials chain — no OIDC provider or IRSA annotation +ceremony. The tagged form +(`class Api extends Kubernetes.Deployment()("Api") {}` + +`Api.make(props, impl)`) works exactly as it does on Lambda and +Cloudflare Workers. + +## Run-to-completion work with Job + +[`Kubernetes.Job`](/providers/kubernetes/job) runs a container to +completion — the Kubernetes analog of `AWS.ECS.Task`. Same three image sources, +same bindings and Pod Identity; an Effect impl returns `{ run }` +instead of `{ fetch }`, executing to completion inside the Pod: + +```typescript +const migrate = yield* Kubernetes.Job("DbMigrate", { + cluster, + image: "ghcr.io/acme/migrator:v3", + backoffLimit: 2, +}); +``` + +Set `schedule` (standard 5-field cron) and a Kubernetes `CronJob` is +synthesized instead of a plain `Job`: + +```typescript +const nightly = yield* Kubernetes.Job("NightlyBackfill", { + cluster, + main: import.meta.url, + schedule: "0 3 * * *", +}); +``` + +## Everything else is a Manifest + +[`Kubernetes.Manifest`](/providers/kubernetes/manifest) applies any +raw Kubernetes object — StatefulSets, Namespaces, CRDs — via server-side apply. +The manifest is a literal object, exactly as you would write it in +YAML: + +```typescript +const namespace = yield* Kubernetes.Manifest("DemoNamespace", { + cluster, + manifest: { + apiVersion: "v1", + kind: "Namespace", + metadata: { name: "demo" }, + }, +}); +``` + +There is no kubeconfig step: Alchemy authenticates to the cluster's +API with your AWS credentials (a presigned STS token) and applies +objects via **server-side apply** under the `alchemy` field manager, +so deploys converge the live objects the same way the rest of your +Stack converges cloud resources. Unknown kinds resolve through the +Kubernetes API discovery endpoint, so CRDs work without any +registration. + +## Install a Helm chart + +[`Kubernetes.HelmChart`](/providers/kubernetes/helmchart) renders a +chart with the +local `helm` CLI (`helm template` — install helm on your machine, +like Docker for image builds) and applies the rendered objects +through the same server-side-apply path as `Manifest`: + +```typescript +const ingress = yield* Kubernetes.HelmChart("IngressNginx", { + cluster, + chart: "ingress-nginx", + repo: "https://kubernetes.github.io/ingress-nginx", + version: "4.11.2", + namespace: "ingress-nginx", + createNamespace: true, + values: { + controller: { replicaCount: 2 }, + }, +}); +``` + +`chart` also accepts `oci://` references and local chart directories, +and `values` is a literal object — the same shape as a `values.yaml` +file. Because the objects are applied (not `helm install`ed), Alchemy +owns their lifecycle: the full render is re-applied whenever the +chart's inputs change, objects that drop out of the render are +pruned, and destroy deletes them — +there is no in-cluster Helm release record. Charts that rely on +install/upgrade hooks for correctness should be installed with Helm +directly. + +## What EKS adds to Kubernetes workloads + +The `Kubernetes.*` resources are cluster-agnostic. When their +`cluster` is an EKS cluster, `AWS.providers()` contributes an +adapter that handles the AWS-specific parts. + +### Authentication + +Alchemy signs a short-lived bearer token with your AWS credentials +for every request, so there is no kubeconfig to manage. It's the +same token `aws eks get-token` produces. The principal that created the +cluster is its admin. Grant anyone else access with +[`AccessEntry`](/providers/aws/eks/accessentry). + +### Images in ECR + +Each `Deployment` and `Job` gets its own private ECR repository. +`main` programs are bundled and built into an image, `context` +Dockerfiles are built, and `image` references are pulled and pushed +into the repository, so pods only pull from your account. Set +`architecture: "arm64"` for Graviton nodes. The default builds +`amd64`. The repository is deleted when the workload is destroyed. + +### Pod Identity + +Each workload gets an IAM role and a +[`PodIdentityAssociation`](/providers/aws/eks/podidentityassociation) +for its ServiceAccount. AWS bindings used by the workload add their +IAM statements to that role, and the pod receives temporary +credentials for it through the EKS Pod Identity agent. Attach +AWS-managed or customer-managed policies with +`identity.managedPolicyArns`: + +```typescript +const exporter = yield* Kubernetes.Job("Export", { + cluster, + main: import.meta.url, + identity: { + managedPolicyArns: ["arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"], + }, +}); +``` + +### Load balancers + +Auto Mode's built-in load balancer controller only handles Services +with `loadBalancerClass: eks.amazonaws.com/nlb`, and creates internal +load balancers by default. On `LoadBalancer` Services Alchemy sets +that class (when the cluster has Auto Mode load balancing enabled) +and the `service.beta.kubernetes.io/aws-load-balancer-scheme: +internet-facing` annotation, so `url` is reachable from the +internet. Your own `serviceAnnotations` take precedence. Set the +scheme to `internal` to keep a service private: + +```typescript +const internal = yield* Kubernetes.Deployment("Internal", { + cluster, + image: "ghcr.io/acme/internal:v1", + port: 8080, + serviceAnnotations: { + "service.beta.kubernetes.io/aws-load-balancer-scheme": "internal", + }, +}); +``` + +### Cleanup + +The ECR repository and Pod Identity role outlive the cluster's own +objects. On destroy Alchemy deletes the in-cluster objects if the +cluster is still reachable, then deletes the repository and role +whether or not the cluster still exists, so destroying a cluster and +its workloads together leaves nothing behind in AWS. + +## Target an EKS cluster you don't manage + +To deploy onto a cluster created elsewhere, name it in a connection. +The adapter looks up the endpoint and certificate authority from the +cluster name: + +```typescript +const cluster: Kubernetes.Connection = { + auth: { kind: "aws-eks", clusterName: "shared-prod", region: "us-west-2" }, +}; +``` + +`region` defaults to the Stack's AWS region. The `aws-eks` auth kind +is added to the `Kubernetes.Connection` type by importing +`alchemy/AWS`, and it requires `AWS.providers()` in the Stack. Your +AWS principal needs an access entry on the cluster. Workloads get +the same ECR, Pod Identity, and load balancer behavior as on a +Stack-managed cluster. + +## Run on SageMaker HyperPod + +An EKS cluster can also orchestrate a +[SageMaker HyperPod](/aws/compute/hyperpod) fleet — persistent, +health-checked ML compute whose nodes join the cluster as +ordinary Kubernetes nodes. +[`AWS.SageMaker.Cluster`](/providers/aws/sagemaker/cluster) +attaches the fleet to the EKS control plane: + +```typescript +const hyperpod = yield* AWS.SageMaker.Cluster("HyperPod", { + orchestrator: { Eks: { ClusterArn: cluster.clusterArn } }, + vpcConfig: { + SecurityGroupIds: [clusterSecurityGroupId], + Subnets: network.privateSubnetIds, + }, + instanceGroups: { + workers: { + InstanceType: "ml.g5.xlarge", + InstanceCount: 4, + ExecutionRole: role.roleArn, + LifeCycleConfig: { + SourceS3Uri: script.sourceS3Uri, + OnCreate: script.onCreate, + }, + }, + }, +}); +``` + +`Kubernetes.Deployment` and `Kubernetes.Job` then opt onto those +nodes in plain Kubernetes vocabulary — the HyperPod resources +expose the derived values as attributes. The instance-group keys +carry through to the cluster's attributes as types — +`hyperpod.instanceGroups.workers` is typed per key and a typo'd +name is a compile error — so the workload is connected to the +fleet through the resource graph, and a team's `ComputeQuota` +materializes the governed namespace and Kueue queue: + +```typescript +const train = yield* Kubernetes.Job("Train", { + cluster, + main: import.meta.url, + namespace: researchQuota.namespace, // hyperpod-ns-research + labels: { + [AWS.SageMaker.KUEUE_QUEUE_NAME_LABEL]: researchQuota.queueName, + [AWS.SageMaker.KUEUE_PRIORITY_CLASS_LABEL]: "training-priority", + }, + podTemplate: { + spec: { nodeSelector: hyperpod.instanceGroups.workers.nodeSelector }, + }, +}); +``` + +See [HyperPod](/aws/compute/hyperpod) for the fleet's +prerequisites (lifecycle scripts, the dependencies Helm chart, +EKS auth-mode and version constraints) and the full governance +surface. + +## Where next + +- [Kubernetes](/kubernetes) — the full guide to `Kubernetes.*` + workloads, manifests, and Helm charts, with a + [tutorial](/kubernetes/tutorial/part-1) you can run on a local + cluster before deploying to EKS. +- [VPC & networking](/aws/networking) — what the `Network` helper + builds under the cluster. +- [HyperPod](/aws/compute/hyperpod) — run training fleets on this + cluster's nodes. +- [Choosing a runtime](/aws/compute/choosing-a-runtime) — when Lambda or ECS + is the better fit than running your own Kubernetes workloads. +- [`Cluster` reference](/providers/aws/eks/cluster), + [`Deployment` reference](/providers/kubernetes/deployment), + [`Job` reference](/providers/kubernetes/job), + [`Manifest` reference](/providers/kubernetes/manifest) — every prop + and attribute. diff --git a/.agents/skills/alchemy/references/aws/compute/hyperpod.md b/.agents/skills/alchemy/references/aws/compute/hyperpod.md new file mode 100644 index 00000000..3d0929d8 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/compute/hyperpod.md @@ -0,0 +1,279 @@ + + +# HyperPod + +> Provision SageMaker HyperPod clusters — Slurm or EKS orchestrated — and run ML workloads on them with sbatch, raw manifests, or effectful Jobs with task governance. + +**SageMaker HyperPod** is AWS's persistent fleet for ML training +and inference: a cluster of accelerated instances with automatic +health checks, faulty-node replacement, and deep health checks +for GPUs. Where ECS and EKS run application containers, HyperPod +runs distributed training — and stays up between jobs. + +The HyperPod primitives: + +- A **Cluster** is the fleet. It is orchestrated by **Slurm** + (the default) or by an **EKS** cluster you attach it to. +- An **instance group** names a set of identical instances — a + controller group, a worker group — each with an instance type, + count, execution role, and lifecycle script. +- A **lifecycle script** (`on_create.sh` in S3) runs on every + node when it boots. This is where real clusters install + schedulers, mount FSx, and wire observability. +- **Task governance** (EKS orchestration only) arbitrates the + fleet between teams: a scheduler policy sets priority classes, + and per-team compute quotas reserve capacity. + +Alchemy models these with +[`Cluster`](/providers/aws/sagemaker/cluster), +[`ClusterSchedulerConfig`](/providers/aws/sagemaker/clusterschedulerconfig), +and [`ComputeQuota`](/providers/aws/sagemaker/computequota), and +Kubernetes workloads opt onto HyperPod nodes by referencing their +attributes (the group's `nodeSelector`, the quota's governed +`namespace` and `queueName`) from plain +[`Kubernetes.Deployment`](/providers/kubernetes/deployment) and +[`Kubernetes.Job`](/providers/kubernetes/job) props. + +## Choose an orchestrator + +| | Slurm (default) | EKS (`orchestrator: { Eks }`) | +| --------------- | ------------------------------------- | ---------------------------------------------------- | +| Workloads | `sbatch` on the login node (over SSM) | Kubernetes objects — `Deployment`, `Job`, `Manifest` | +| High-level DX | — | `Kubernetes.Job` / `Deployment` via HyperPod attributes | +| Governance | Slurm accounting | `ClusterSchedulerConfig` + `ComputeQuota` (Kueue) | +| Extra plumbing | Lifecycle bucket + script | EKS cluster + the HyperPod dependencies Helm chart | + +Pick Slurm for classic HPC-style training queues. Pick EKS when +your platform is Kubernetes or you want typed workloads and task +governance. + +## Create a Slurm cluster + +A Slurm cluster needs three things: an S3 bucket holding the +lifecycle script, an execution role the nodes assume, and the +cluster itself. The script must exist in S3 before the cluster +creates — a deploy-time `Action` uploads it, and the data flow +(bucket → script → cluster) orders the deploy: + +```typescript +const bucket = yield* AWS.S3.Bucket("LifecycleScripts", { + forceDestroy: true, +}); + +const script = yield* UploadLifecycleScript({ + bucketName: bucket.bucketName, +}); + +const role = yield* AWS.IAM.Role("HyperPodInstanceRole", { + assumeRolePolicyDocument: { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { Service: "sagemaker.amazonaws.com" }, + Action: ["sts:AssumeRole"], + }, + ], + }, + managedPolicyArns: [ + "arn:aws:iam::aws:policy/AmazonSageMakerClusterInstanceRolePolicy", + ], +}); + +const cluster = yield* AWS.SageMaker.Cluster("TrainingCluster", { + instanceGroups: { + controller: { + InstanceType: "ml.t3.medium", + InstanceCount: 1, + ExecutionRole: role.roleArn, + LifeCycleConfig: { + SourceS3Uri: script.sourceS3Uri, + OnCreate: script.onCreate, + }, + }, + }, +}); +``` + +Instance groups reconcile in place: change a count or add a group +and the cluster updates; remove a group from `instanceGroups` and +it is deleted from the cluster. Changing the VPC replaces the +cluster. Provisioning takes ~5 minutes for small CPU groups and +10–25 minutes for large GPU groups. + +The full working stack is +[`examples/aws-hyperpod`](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-hyperpod). + +## Submit Slurm jobs + +Slurm has no remote submission API — jobs are submitted on the +cluster. Every node is an SSM target named +`sagemaker-cluster:_-`: + +```sh +aws sagemaker list-cluster-nodes --cluster-name +aws ssm start-session \ + --target sagemaker-cluster:_controller- +# then, on the node: +sbatch --nodes=4 train.sbatch +``` + +## Create an EKS-orchestrated cluster + +Under EKS orchestration, HyperPod nodes join an EKS cluster you +provide and workloads are ordinary Kubernetes objects. The API +enforces a few constraints, all encoded in the example: + +- The EKS cluster must use the **`API`** (or `API_AND_CONFIG_MAP`) + authentication mode — EKS's own CONFIG_MAP default is rejected. +- HyperPod trails the newest Kubernetes version; pin one it + supports (1.28–1.35 today). +- `LifeCycleConfig` is required for EKS-orchestrated instance + groups too. +- The **HyperPod dependencies Helm chart** + ([aws/sagemaker-hyperpod-cli](https://github.com/aws/sagemaker-hyperpod-cli)) + must be installed on the EKS cluster before the HyperPod + cluster attaches — SageMaker validates it. The example fetches + the chart with an `Action` and applies it with + [`Kubernetes.HelmChart`](/providers/kubernetes/helmchart). + +```typescript +const eks = yield* AWS.EKS.Cluster("Orchestrator", { + roleArn: eksRole.roleArn, + version: "1.34", + resourcesVpcConfig: { subnetIds: network.privateSubnetIds }, + accessConfig: { + authenticationMode: "API", + bootstrapClusterCreatorAdminPermissions: true, + }, +}); + +const hyperpod = yield* AWS.SageMaker.Cluster("HyperPod", { + orchestrator: { Eks: { ClusterArn: eks.clusterArn } }, + vpcConfig: { + SecurityGroupIds: [clusterSecurityGroupId], + Subnets: network.privateSubnetIds, + }, + instanceGroups: { + workers: { + InstanceType: "ml.t3.medium", + InstanceCount: 1, + ExecutionRole: role.roleArn, + LifeCycleConfig: { + SourceS3Uri: script.sourceS3Uri, + OnCreate: script.onCreate, + }, + }, + }, +}); +``` + +HyperPod nodes must live in **private subnets** — the +[`Network`](/providers/aws/ec2/network) helper with `nat: +"single"` builds a suitable VPC. + +## Run workloads on HyperPod nodes + +HyperPod nodes are ordinary EKS nodes carrying well-known labels. +[`Kubernetes.Deployment`](/providers/kubernetes/deployment) and +[`Kubernetes.Job`](/providers/kubernetes/job) opt onto them with +each instance group's `nodeSelector` attribute — health-checked +nodes of that group. The instance-group keys carry through to the +cluster's attributes as types, so the reference is typed per key +(a typo'd group name is a compile error) and the workload is +connected to the fleet through the resource graph: + +```typescript +const train = yield* Kubernetes.Job( + "Train", + { + cluster: eks, + main: import.meta.url, + podTemplate: { + spec: { + nodeSelector: hyperpod.instanceGroups.workers.nodeSelector, + }, + }, + }, + Effect.gen(function* () { + return { + run: Effect.gen(function* () { + // training / eval logic; bindings land IAM on pod identity + }), + }; + }), +); +``` + +For anything the typed surface doesn't cover — a Kubeflow +`PyTorchJob`, a custom operator — apply a raw +[`Kubernetes.Manifest`](/providers/kubernetes/manifest) pinned by +the node labels directly: + +```typescript +nodeSelector: { + "sagemaker.amazonaws.com/node-health-status": "Schedulable", + "sagemaker.amazonaws.com/instance-group-name": "workers", +}, +``` + +## Task governance + +Task governance arbitrates the fleet between teams. It ships as +the `amazon-sagemaker-hyperpod-taskgovernance` EKS add-on (Kueue +under the hood), a **scheduler policy** with priority classes, +and per-team **compute quotas**: + +```typescript +yield* AWS.EKS.Addon("TaskGovernance", { + clusterName: eks.clusterName, + addonName: "amazon-sagemaker-hyperpod-taskgovernance", +}); + +// One policy per cluster — a second create fails with the typed +// ClusterSchedulerConfigAlreadyExists error. +const policy = yield* AWS.SageMaker.ClusterSchedulerConfig("Scheduler", { + clusterArn: hyperpod.clusterArn, + schedulerConfig: { + PriorityClasses: [ + { Name: "inference", Weight: 100 }, + { Name: "training", Weight: 75 }, + ], + FairShare: "Enabled", + }, +}); + +// Creates the hyperpod-ns-research namespace + its Kueue queue. +const quota = yield* AWS.SageMaker.ComputeQuota("ResearchQuota", { + clusterArn: hyperpod.clusterArn, + computeQuotaTarget: { TeamName: "research", FairShareWeight: 10 }, + computeQuotaConfig: { + ComputeQuotaResources: [{ InstanceType: "ml.t3.medium", Count: 1 }], + }, +}); +``` + +A workload submits through governance by referencing the quota's +attributes — the governed namespace and the team's Kueue queue — +which also orders the workload after the quota: + +```typescript +namespace: quota.namespace, // hyperpod-ns-research +labels: { + [AWS.SageMaker.KUEUE_QUEUE_NAME_LABEL]: quota.queueName, + [AWS.SageMaker.KUEUE_PRIORITY_CLASS_LABEL]: "training-priority", +}, +``` + +## Where next + +- [`examples/aws-hyperpod`](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-hyperpod) + — both orchestrators, every workload tier, task governance +- [EKS](/aws/compute/eks) — the Kubernetes surface HyperPod + workloads ride on +- [`Cluster`](/providers/aws/sagemaker/cluster), + [`ClusterSchedulerConfig`](/providers/aws/sagemaker/clusterschedulerconfig), + [`ComputeQuota`](/providers/aws/sagemaker/computequota) — API + reference diff --git a/.agents/skills/alchemy/references/aws/compute/lambda.md b/.agents/skills/alchemy/references/aws/compute/lambda.md new file mode 100644 index 00000000..c3e07b74 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/compute/lambda.md @@ -0,0 +1,343 @@ + + +# Lambda + +> Stand up an AWS Lambda Function from a single Effect, expose it over a Function URL, and call it from a test. + +**Lambda** is Alchemy's default AWS runtime: a class that bundles +an Effect program into a zip, deploys it as a Lambda Function, +and generates its IAM execution role from the bindings you +actually use. Serve HTTP over a public **Function URL**, or +consume events — every building block in this section plugs in +through the same pattern: S3 notifications +(`Lambda.BucketEventSource`), SQS queues +(`Lambda.QueueEventSource`), Kinesis streams +(`Lambda.StreamEventSource`), and DynamoDB Streams +(`Lambda.TableEventSource`). + +This page stands up the smallest possible function — a public +HTTP endpoint — which the [S3](/aws/data/s3), +[DynamoDB](/aws/data/dynamodb), [SQS](/aws/messaging/sqs), and +[Kinesis](/aws/messaging/kinesis) pages then grow into a small +event-driven system. + +## Prerequisites + +Install Alchemy and connect your AWS account — see +[Setup](/aws/setup). + +## Create the Stack + +Every Alchemy app has an `alchemy.run.ts` at the root that +declares the resources to deploy. Create one with the AWS +providers wired in: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "MyApp", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { + return {}; + }), +); +``` + +`Alchemy.Stack` is the root of every app. The first argument +(`"MyApp"`) doubles as a logical id and as the prefix Alchemy +uses when AWS asks for physical resource names. + +`providers: AWS.providers()` registers every AWS resource and +IAM policy binding that ships with Alchemy, and resolves +credentials from your Alchemy profile — SSO, environment +variables, or stored keys, whichever you picked in +[Setup](/aws/setup). + +`state: AWS.state()` stores deploy state in an account-regional +S3 bucket (`alchemy-state-{accountId}-{region}-an`), created +lazily on the first deploy. Because the state lives in your AWS +account rather than on disk, the same configuration works +locally and in CI. (For purely local iteration you can use +`Alchemy.localState()` instead, which writes state under +`.alchemy/` next to your code.) + +The trailing `Effect.gen` block is where you'll declare the +resources to deploy. It's empty for now. + +## Declare a Lambda Function + +A Lambda Function in Alchemy is a class. Create `src/api.ts` +with the smallest possible declaration — just the class +ceremony, an entrypoint, and an empty runtime: + +```typescript +// src/api.ts +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; + +export default class Api extends AWS.Lambda.Function()( + "Api", + { main: import.meta.url }, + Effect.gen(function* () { + return {}; + }), +) {} +``` + +The `` type argument plus the empty `()` is a one-time bit +of ceremony — it lets TypeScript reason about `Api` as a typed +handle that other resources can later bind against. The rest of +your code looks completely normal. + +`main: import.meta.url` tells Alchemy this same file is the +bundle entrypoint. At deploy time it'll be bundled with Rolldown +into a zip and uploaded as the function's code. + +## Serve HTTP from a Function URL + +The empty function compiles, but it doesn't do anything yet. +Add a `fetch` field — Alchemy treats anything returned from the +`Effect.gen` block as the runtime API, and `fetch` specifically +is wired up to handle incoming HTTP requests: + +```diff lang="typescript" +// src/api.ts +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; ++import * as HttpServerResponse from "effect/http/HttpServerResponse"; + +export default class Api extends AWS.Lambda.Function()( + "Api", + { main: import.meta.url }, + Effect.gen(function* () { +- return {}; ++ return { ++ fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), ++ }; + }), +) {} +``` + +`HttpServerResponse.text(...)` is the same `effect/http` +API used everywhere else — Alchemy adapts it to the Lambda event +envelope under the hood, so your handler never sees the raw +`APIGatewayProxyEvent` shape. + +## Expose a public URL + +`fetch` exists, but no one can call it yet. Set `functionUrl: true` on +the props to ask AWS for a public **Function URL** — no API +Gateway, no auth, just a public HTTPS endpoint: + +```diff lang="typescript" +export default class Api extends AWS.Lambda.Function()( + "Api", +- { main: import.meta.url }, ++ { main: import.meta.url, functionUrl: true }, + Effect.gen(function* () { + return { + fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), + }; + }), +) {} +``` + +The resolved `Api` resource will now expose a `functionUrl` +field carrying that endpoint — we'll surface it from the Stack +in a moment. + +## Customize per stage + +Lambda has knobs you'll want to tune per stage — memory, +timeout, log retention. Swap the static props object for +`Stack.useSync`, which gives you a synchronous accessor for any +value in the surrounding Effect context: + +```diff lang="typescript" +// src/api.ts +import * as AWS from "alchemy/AWS"; ++import { Stack } from "alchemy/Stack"; +import * as Effect from "effect/Effect"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; + +export default class Api extends AWS.Lambda.Function()( + "Api", +- { main: import.meta.url, functionUrl: true }, ++ Stack.useSync((stack) => ({ ++ main: import.meta.url, ++ functionUrl: true, ++ memory: stack.stage === "prod" ? 1024 : 512, ++ })), + Effect.gen(function* () { + return { + fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), + }; + }), +) {} +``` + +`Stack.useSync` is the synchronous accessor for any value in the +surrounding Effect context — handy for stack-level config like +`stage`, `appName`, or anything else you'd want to vary per +environment. + +## Wire the function into the Stack + +The `Api` class is just a typed identifier — yielding it inside +the Stack's Effect is what registers the resource and starts the +deploy: + +```diff lang="typescript" +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; ++import Api from "./src/api.ts"; + +export default Alchemy.Stack( + "MyApp", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { ++ const api = yield* Api; ++ return { url: api.functionUrl }; +- return {}; + }), +); +``` + +Yielding `Api` returns the resolved Lambda outputs — the function +ARN, role ARN, log group, and the public Function URL we asked +for with `functionUrl: true`. We surface `functionUrl` as the Stack's +`url` so the test harness can find it. + +## Deploy + +```sh +bun alchemy deploy +``` + +Alchemy bundles `src/api.ts` with Rolldown, packages it into a +zip, creates the IAM execution role, uploads the function, and +provisions the Function URL. The first deploy takes a moment +because of role propagation; subsequent deploys are seconds. + +## Verify + +Drop a quick integration test that hits the Function URL and +checks the body: + +```typescript +// test/integ.test.ts +import * as AWS from "alchemy/AWS"; +import * as Test from "alchemy/Test/Bun"; +import { expect } from "bun:test"; +import * as Effect from "effect/Effect"; +import * as HttpClient from "effect/http/HttpClient"; +import Stack from "../alchemy.run.ts"; + +const { test, beforeAll, deploy } = Test.make({ + providers: AWS.providers(), + state: AWS.state(), +}); + +const stack = beforeAll(deploy(Stack)); + +test( + "Api responds over Function URL", + Effect.gen(function* () { + const { url } = yield* stack; + const response = yield* HttpClient.get(url); + expect(yield* response.text).toBe("Hello from Lambda!"); + }), +); +``` + +```sh +bun test test/integ.test.ts +``` + +You now have a deployable Lambda with a public URL. + +## Sandbox scope vs invocation scope + +The constructor runs **once per sandbox**, at cold start — +everything it builds (bindings, SDK clients) is reused by every +invocation. Each invocation then runs with a **fresh `Scope`** that +is settled *inline* before the handler returns — so +`Effect.addFinalizer` in a handler runs before the response leaves, +and request finalizers should be fast (closing a pool is +milliseconds). + +:::caution[There is no `waitUntil` on Lambda — request finalizers block the response] +No mechanism delivers post-response work reliably on Lambda. A +buffered invocation's response is not released to the caller until +the entire Invoke phase completes, so deferring cleanup past the +handler (we measured an extension-held post-response window) still +shows up as response latency — and environments whose invoke phase +is held open this way get recycled. Dangling promises resume on a +later thaw at best: TCP connections rarely survive the freeze, and a +crash or timeout reset drops the work entirely — silent data loss +for anything that mattered. + +The contract is blocking and honest: request finalizers run before +the response, keep them cheap, and anything that must not be lost +gets written durably (a queue, a table) inside the handler itself. +::: + +Construction-level finalizers run at **sandbox shutdown**. A Lambda sandbox +with no registered extensions is killed with no signal at all, so +the generated entry registers an internal extension with the +Extensions API — that makes Lambda send `SIGTERM` and allow 500 ms +before `SIGKILL`, and the entry uses the window to close the +instance scope: + +```typescript +Effect.gen(function* () { + // Construction: runs once per sandbox + yield* Effect.addFinalizer(() => + // runs in the 500 ms SIGTERM window at sandbox spin-down + flushTelemetry().pipe(Effect.ignore), + ); + return { fetch: /* per-invocation */ }; +}); +``` + +Treat instance-level cleanup as best-effort: half a second, and not +delivered on hard failures (a timeout reset kills the runtime +without the signal). Flush caches and close connections there; +anything that *must* happen belongs in the handler, scoped to the +invocation. See +[Instance scope vs request scope](/infrastructure-as-effects/runtime#instance-scope-vs-request-scope) +for the model across all runtimes. + +## Where next + +- [Drizzle + Aurora PostgreSQL](/aws/data/drizzle-aurora) — private database access, + IAM authentication, verified TLS, and deployment-time schema setup. +- [Drizzle + Aurora DSQL](/aws/data/drizzle-dsql) — the separate serverless database, + with an IAM-authenticated endpoint and non-admin application role. + +- [S3](/aws/data/s3) — add a bucket and bind read/write operations + into this function; IAM policies generated from the call + sites, no policy JSON to hand-write. +- [Secrets & env](/aws/security/secrets-env) — API keys from .env and + Secrets Manager credentials for this function. +- [REST API (API Gateway v1)](/aws/apis/api-gateway) — put an + API Gateway REST API with stages and custom domains in front + instead of a Function URL. +- [Choosing a runtime](/aws/compute/choosing-a-runtime) — when ECS, + EKS, or EC2 fits better than Lambda. +- [`Function` reference](/providers/aws/lambda/function) — every + prop and attribute. diff --git a/.agents/skills/alchemy/references/aws/compute/microvms.md b/.agents/skills/alchemy/references/aws/compute/microvms.md new file mode 100644 index 00000000..1e91e2ef --- /dev/null +++ b/.agents/skills/alchemy/references/aws/compute/microvms.md @@ -0,0 +1,246 @@ + + +# Lambda MicroVMs + +> Build a Firecracker MicroVM image from TypeScript or a Dockerfile, then launch and drive isolated stateful instances from a Lambda Function with typed lifecycle bindings and RPC. + +A Lambda **MicroVM image** is a Firecracker snapshot that boots a fully +initialized application in milliseconds. The model is *image-then-launch*: +`AWS.Lambda.MicrovmImage` builds the image once at deploy time, then a Lambda +Function launches isolated, stateful MicroVM instances from it at runtime — +one per end-user or session. That makes MicroVMs a natural fit for per-user +sandboxes: code interpreters, agent workspaces, preview environments. + +The build runs **server-side on AWS** — no local Docker. Alchemy produces the +code artifact in one of three modes, selected by which prop you set: + +- **Effectful** (`main`) — write the in-VM HTTP server in TypeScript; Alchemy + bundles it, generates a Dockerfile, and uploads the zip. +- **External** (`context`/`dockerfile`) — bring your own Dockerfile and build + context in any language; Alchemy zips and uploads the directory. +- **Prebuilt** (`codeArtifact`) — point at an existing S3 zip or ECR image URI. + +:::caution +Lambda MicroVMs are a **preview feature**: your account must be onboarded to +the preview, and effectful/external builds need a bootstrapped Assets bucket +(`alchemy provider aws bootstrap`). Builds are asynchronous and can take several +minutes — the provider polls until the image reaches `CREATED` and streams +build logs to CloudWatch. +::: + +## Define an effectful image + +Write the in-VM server as an Effect. The class is a typed handle the +orchestrator imports; the default-exported `.make()` is what gets bundled into +the image. The shape can expose both a typed **RPC method** (`hello`) and a +raw **`fetch`** route — the in-VM runtime serves RPC over an internal +`/__rpc__/*` protocol and falls through to `fetch` for everything else. The +`{ fetch, ...rpcs }` shape is [Schemaless RPC](/apis/schemaless) — the same +contract every Function and Server returns: + +```typescript +// sandbox.ts +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; + +export const BuildRole = AWS.IAM.Role("MicrovmBuildRole"); + +export class Sandbox extends AWS.Lambda.MicrovmImage< + Sandbox, + { hello: (message: string) => Effect.Effect } +>()("Sandbox") {} + +export default Sandbox.make( + BuildRole.pipe( + Effect.map((buildRole) => ({ main: import.meta.filename, buildRole })), + ), + Effect.gen(function* () { + return { + // typed RPC method — reached with `connectMicrovm` + hello: (message: string) => Effect.succeed(`hello, ${message}!`), + // raw HTTP route — reached with a plain HTTPS request to the endpoint + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const url = new URL(request.url, "http://microvm"); + return yield* HttpServerResponse.json({ + message: url.searchParams.get("message") ?? "", + }); + }), + }; + }), +); +``` + +The bare `Role` passed as `buildRole` needs no policy of its own — the image +grants it the Lambda trust statement plus the S3 and CloudWatch-logs +permissions the build needs via a binding. + +## Launch and drive MicroVMs from a Lambda + +The image is just the template. A Lambda Function binds the per-instance +lifecycle operations — `RunMicrovm`, `GetMicrovm`, `CreateAuthToken`, +`TerminateMicrovm` — each scoped to the image by IAM automatically. Launch a +VM, wait for `RUNNING`, mint an auth token, then call the typed RPC stub with +`connectMicrovm`: + +```typescript +// api.ts +import * as AWS from "alchemy/AWS"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; +import { Sandbox } from "./sandbox.ts"; + +export default class Api extends AWS.Lambda.Function()( + "Api", + // generous timeout: the handler waits for the MicroVM to reach RUNNING + { main: import.meta.filename, functionUrl: true, timeout: Duration.seconds(120) }, + Effect.gen(function* () { + const runMicrovm = yield* AWS.Lambda.RunMicrovm(Sandbox); + const getMicrovm = yield* AWS.Lambda.GetMicrovm(Sandbox); + const createAuthToken = yield* AWS.Lambda.CreateAuthToken(Sandbox); + const terminateMicrovm = yield* AWS.Lambda.TerminateMicrovm(Sandbox); + + return { + fetch: Effect.gen(function* () { + const vm = yield* runMicrovm({}); + return yield* Effect.gen(function* () { + // wait until the MicroVM is RUNNING before connecting + yield* getMicrovm({ microvmIdentifier: vm.microvmId }).pipe( + Effect.flatMap((m) => + m.state === "RUNNING" + ? Effect.void + : Effect.fail(new Error(`microvm ${m.state}`)), + ), + Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 30 }), + ); + const { authToken } = yield* createAuthToken({ + microvmIdentifier: vm.microvmId, + expirationInMinutes: 5, + allowedPorts: [{ port: 8080 }], // the in-VM server's port + }); + const sandbox = yield* AWS.Lambda.connectMicrovm(Sandbox, { + endpoint: vm.endpoint, + authToken, + }); + const reply = yield* sandbox.hello("world"); // "hello, world!" + return yield* HttpServerResponse.json({ reply }); + }).pipe( + // terminate on success OR failure — never leak a running MicroVM + Effect.ensuring( + terminateMicrovm({ microvmIdentifier: vm.microvmId }).pipe( + Effect.ignore, + ), + ), + // the in-VM endpoint calls need an HttpClient for this scope + Effect.provide(FetchHttpClient.layer), + ); + }), + }; + }).pipe( + Effect.provide( + Layer.mergeAll( + AWS.Lambda.RunMicrovmHttp, + AWS.Lambda.GetMicrovmHttp, + AWS.Lambda.CreateAuthTokenHttp, + AWS.Lambda.TerminateMicrovmHttp, + ), + ), + ), +) {} +``` + +The stub is [Schemaless RPC](/apis/schemaless) — the same typed client you get +binding a Durable Object or Container into a Worker, here a MicroVM into a +Lambda Function. + +Always wrap the work in `Effect.ensuring(terminateMicrovm(...))` — a failure +(or a client retry) must never leak a running MicroVM against your account's +memory quota. `SuspendMicrovm`, `ResumeMicrovm`, and `ListMicrovms` follow the +same bind-then-call pattern for longer-lived sessions. + +## Call the fetch route directly + +The `fetch` handler is plain HTTPS against the MicroVM's endpoint (a bare +hostname like `.lambda-microvm..on.aws`). Authenticate with the +same token via `microvmAuthHeaders`: + +```typescript +const client = yield* HttpClient.HttpClient; +const res = yield* client.get(`https://${vm.endpoint}/echo?message=hi`, { + headers: AWS.Lambda.microvmAuthHeaders(authToken), +}); +const body = yield* res.json; // { message: "hi" } +``` + +Both surfaces coexist on one image: the RPC stub for typed calls from your own +code, the fetch route for anything that speaks HTTP. + +## Wire the stack + +The Lambda pulls in the `Sandbox` class handle; the image's `.make()` runtime +is provided on the Stack so it gets built and deployed alongside: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; +import Api from "./api.ts"; +import SandboxLive from "./sandbox.ts"; + +export default Alchemy.Stack( + "MicrovmApp", + { providers: AWS.providers(), state: AWS.state() }, + Effect.gen(function* () { + const api = yield* Api; + return { url: api.functionUrl.as() }; + }).pipe(Effect.provide(SandboxLive)), +); +``` + +## Bring your own Dockerfile + +For non-TypeScript workloads, point `context` at a directory containing a +Dockerfile (built `FROM public.ecr.aws/lambda/microvms:al2023-minimal`) and +the files it copies. Alchemy zips and uploads the directory; AWS runs the +Dockerfile server-side. There is no in-VM Effect runtime to bind, so both +the bare build Role and the image live directly in the Stack's `Effect.gen`: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "FlaskMicrovm", + { providers: AWS.providers(), state: AWS.state() }, + Effect.gen(function* () { + const buildRole = yield* AWS.IAM.Role("ExternalBuildRole", {}); + const image = yield* AWS.Lambda.MicrovmImage("Flask", { + context: `${import.meta.dirname}/app`, // dir with Dockerfile + app.py + buildRole, + }); + return { imageArn: image.imageArn.as() }; + }), +); +``` + +Re-deploys only trigger a new build when the artifact's content hash or a +build-affecting prop changes; otherwise the image is left untouched. + +## Where next + +- [Lambda](/aws/compute/lambda) — the Function model the orchestrator is built on. +- [MicrovmImage API reference](/providers/aws/lambda/microvmimage) — all props, + build modes, sizing, logging, and VPC egress via `NetworkConnector`. +- [RunMicrovm](/providers/aws/lambda/runmicrovm) and the other per-instance + lifecycle bindings. diff --git a/.agents/skills/alchemy/references/aws/data/drizzle-aurora.md b/.agents/skills/alchemy/references/aws/data/drizzle-aurora.md new file mode 100644 index 00000000..538d1ad5 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/data/drizzle-aurora.md @@ -0,0 +1,218 @@ + + +# Drizzle + Aurora PostgreSQL + +> Drizzle on Lambda with private Aurora PostgreSQL, IAM authentication, verified TLS, and reviewed SQL migrations. + +[Runnable example](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-aurora-drizzle) · [AWS setup](/aws/setup) · [Aurora DSQL instead](/aws/data/drizzle-dsql) + +## Prerequisites + +```sh +git clone https://github.com/alchemy-run/alchemy.git +cd alchemy +pnpm install +cd examples/aws-aurora-drizzle +``` + +The example uses `us-west-2`, Aurora PostgreSQL 17.5, and Node.js 22. Change both subnet availability zones if you change region. Application queries use Effect-native Drizzle over `pg`, not the RDS Data API driver; [SQL databases](/sql/databases) compares the integrations. + +:::caution +Aurora is billable until destroyed. This example is disposable: destroy deletes its database data. +::: + +## Provision a private database + +```typescript +const aurora = yield* AWS.RDS.Aurora("Database", { + databaseName: "app", + engine: "aurora-postgresql", + engineVersion: "17.5", + subnetIds, + securityGroupIds: [dbSecurityGroup.groupId], + secret: { username: "dbadmin" }, + dataApi: true, + cluster: { + enableIAMDatabaseAuthentication: true, + serverlessV2ScalingConfiguration: { MinCapacity: 0.5, MaxCapacity: 1 }, + }, + instance: { dbInstanceClass: "db.serverless", publiclyAccessible: false }, +}); +``` + +[Network setup](https://github.com/alchemy-run/alchemy/blob/main/examples/aws-aurora-drizzle/src/database.ts) allows port 5432 only from the Lambda's security group. [Aurora options](/aws/data/rds). + +## Initialize the schema during deployment + +```typescript +import { bootstrap } from "./bootstrap.ts"; + +const schemaVersion = yield* bootstrap; +``` + +The [deployment Action](https://github.com/alchemy-run/alchemy/blob/main/examples/aws-aurora-drizzle/src/bootstrap.ts) creates `todos` and the restricted `app_iam` login through the Data API. This is initial setup only, not a versioned migration runner. The application role gets table CRUD but cannot create objects in `public`. + +## Bind the application identity + +```typescript +const connect = yield* AWS.RDS.Connect(aurora.cluster, { + auth: "iam", + username: "app_iam", + database: "app", + subnetIds, + securityGroupIds: [lambdaSecurityGroup.groupId], +}); +``` + +Provide `AWS.RDS.ConnectHttp` on the Lambda Effect. The binding attaches Lambda to the VPC and grants `rds-db:connect` for `app_iam`; runtime authentication signs an IAM token rather than reading the administrator secret. [Complete handler](https://github.com/alchemy-run/alchemy/blob/main/examples/aws-aurora-drizzle/src/Api.ts). + +## Verify the database certificate + +Set the Lambda environment: + +```typescript +env: { NODE_EXTRA_CA_CERTS: "/var/runtime/ca-cert.pem" } +``` + +The RDS binding currently emits `sslmode=no-verify`. Override it to require certificate and hostname verification: + +```typescript +import * as Drizzle from "alchemy/Drizzle/Postgres"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; + +const db = yield* Drizzle.Postgres( + connect.pipe( + Effect.map((connection) => { + const url = new URL(Redacted.value(connection.url)); + url.searchParams.set("sslmode", "verify-full"); + return Redacted.make(url.toString()); + }), + ), +); +``` + +The CA path is specific to [managed Lambda Node.js runtimes](https://docs.aws.amazon.com/lambda/latest/dg/lambda-nodejs.html#nodejs-certificate-loading). The connection stays lazy: the first query resolves the redacted URL and opens a pool scoped to that invocation, which closes when its scope ends. + +## Package the driver + +```sh +pnpm add alchemy effect@4.0.0-rc.115 @effect/sql-pg@4.0.0-rc.115 \ + drizzle-orm@1.0.0-rc.5-ab785fc pg +pnpm add -D @types/pg +``` + +```typescript +build: { install: ["pg"] } +``` + +`build.install` keeps the CommonJS `pg` package intact in the Lambda artifact. + +## Query with Drizzle + +```typescript +const rows = yield* db.select().from(todos); +``` + +[Schema and queries](/sql/drizzle/postgres) · [Connection lifecycle](/sql/effect-sql/lifecycle) + +## Deploy and invoke + +```sh +pnpm deploy --profile testing --stage aurora-example +aws lambda invoke --region us-west-2 \ + --function-name '' \ + --cli-binary-format raw-in-base64-out \ + --payload file://health-event.json response.json +``` + +Use the printed function name. The Function URL requires [SigV4-signed requests](https://github.com/alchemy-run/alchemy/blob/main/examples/aws-aurora-drizzle/test/integ.test.ts). + +## Evolve the schema + +The example's bootstrap does not alter existing tables or maintain Drizzle migration history. Before adopting generated migrations for an already-bootstrapped database, establish a reviewed baseline matching the existing schema; do not replay an initial `CREATE TABLE` migration over it. + +### Configure SQL generation + +```sh +pnpm add -D drizzle-kit@1.0.0-rc.5-ab785fc +``` + +```typescript +// drizzle.config.ts +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "postgresql", + schema: "./src/schema.ts", + out: "./migrations", +}); +``` + +Generation compares the [schema module](https://github.com/alchemy-run/alchemy/blob/main/examples/aws-aurora-drizzle/src/schema.ts) with local snapshots and does not need a database connection. + +### Generate after a schema change + +```sh +pnpm exec drizzle-kit generate --config=drizzle.config.ts +``` + +Run this whenever `src/schema.ts` changes, before deployment. Do not put SQL generation in a deploy hook. + +### Review and commit the migration + +```sh +git add src/schema.ts drizzle.config.ts migrations/ +git diff --cached -- src/schema.ts drizzle.config.ts migrations/ +git commit -m "feat(db): update Aurora schema and migration" +``` + +Review the staged SQL, snapshots, destructive operations, and rename decisions before committing. Commit the entire generated migration directory with the schema. The release process consumes committed SQL; deployment must not generate or silently rewrite it. + +### Configure the migration connection + +```typescript +// drizzle.migrate.config.ts +import { defineConfig } from "drizzle-kit"; +import config from "./drizzle.config.ts"; + +const url = process.env.DATABASE_URL; +if (!url) throw new Error("DATABASE_URL is required for migrations"); + +export default defineConfig({ + ...config, + dbCredentials: { url }, +}); +``` + +Inject a migration-role PostgreSQL URL for the `app` database through the runner's secret store, using `sslmode=verify-full` and the RDS CA trust bundle. The runner needs its own network route and security-group access to the private cluster; Lambda's binding grants neither to a laptop or CI job. Keep this DDL-capable identity separate from `app_iam`. + +### Apply the committed SQL + +```sh +pnpm exec drizzle-kit migrate --config=drizzle.migrate.config.ts +``` + +Run from the authorized migration runner before releasing code that requires the new schema. Keep an existing `drizzle-kit migrate` workflow; `alchemy deploy` is not its replacement. Neither `AWS.RDS.Aurora` nor `AWS.RDS.DBCluster` accepts a `migrations` prop, and the example's Action only performs its explicit bootstrap SQL. + +## Run the integration test + +```sh +AWS_TEST_SLOW=1 ALCHEMY_PROFILE=testing bun test test/integ.test.ts +``` + +This deploys real AWS resources and destroys them afterward; provisioning and cleanup take several minutes. + +## Clean up + +```sh +pnpm destroy --profile testing --stage aurora-example +``` + +Wait for deletion to finish. Reuse the same profile and stage if cleanup is interrupted. + +## Next steps + +[SQL databases](/sql/databases) · [Aurora configuration](/aws/data/rds) · [Lambda](/aws/compute/lambda) diff --git a/.agents/skills/alchemy/references/aws/data/drizzle-dsql.md b/.agents/skills/alchemy/references/aws/data/drizzle-dsql.md new file mode 100644 index 00000000..0fcdb33f --- /dev/null +++ b/.agents/skills/alchemy/references/aws/data/drizzle-dsql.md @@ -0,0 +1,203 @@ + + +# Drizzle + Aurora DSQL + +> Drizzle on Lambda with Aurora DSQL, a non-admin database role, verified TLS, and an explicit DSQL migration boundary. + +[Runnable example](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-dsql-drizzle) · [AWS setup](/aws/setup) · [Aurora PostgreSQL instead](/aws/data/drizzle-aurora) + +## Start from the example + +```sh +git clone https://github.com/alchemy-run/alchemy.git +cd alchemy +pnpm install +cd examples/aws-dsql-drizzle +``` + +Use a DSQL-supported region such as `us-west-2`. Deployment needs `dsql:DbConnectAdmin` and access to the cluster on port 5432. The example includes Effect 4, Effect-native Drizzle, `@effect/sql-pg`, and `pg`; it is not an RDS Data API integration. + +:::caution +These resources are billable. Deletion protection is disabled; destroy deletes the database data. +::: + +## Create the cluster + +```typescript +import * as AWS from "alchemy/AWS"; + +export const Database = AWS.DSQL.Cluster("Database", { + deletionProtectionEnabled: false, +}); +``` + +DSQL provides the `postgres` database and a public PostgreSQL-wire endpoint authenticated through IAM; no VPC attachment or password secret is needed. It is not the Aurora cluster-and-writer topology. [DSQL cluster reference](/providers/aws/dsql/cluster) · [SQL databases](/sql/databases). + +## Define the table + +```typescript +import { boolean, pgSchema, text, timestamp, uuid } from "drizzle-orm/pg-core"; + +export const Todos = pgSchema("app").table("todos", { + id: uuid("id").primaryKey(), + text: text("text").notNull(), + done: boolean("done").notNull().default(false), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); +``` + +Use application-generated UUIDs for this table rather than PostgreSQL sequence defaults. Check [DSQL's PostgreSQL compatibility](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/working-with-postgresql-compatibility-migration-guide.html) before extending the schema; PostgreSQL SQL generation is not a portability guarantee. + +## Separate schema setup from application access + +```typescript +const schemaVersion = yield* BootstrapDatabase({ + endpoint: cluster.endpoint, + roleArn: api.roleArn, + version: "1", +}); +``` + +The [deployment Action](https://github.com/alchemy-run/alchemy/blob/main/examples/aws-dsql-drizzle/src/bootstrap.ts) creates `app.todos` and maps the Lambda role to `app_user` using `AWS IAM GRANT`. It grants schema usage and table CRUD, not schema administration. Its privileged connection verifies TLS and executes catalog changes as separate autocommit statements; this is initial setup, not a versioned migration runner. + +## Connect from Lambda + +```typescript +import * as Drizzle from "alchemy/Drizzle/Postgres"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; + +const cluster = yield* Database; +const connect = yield* AWS.DSQL.Connect(cluster, { username: "app_user" }); +const db = yield* Drizzle.Postgres( + connect.pipe( + Effect.map((connection) => { + const url = new URL(Redacted.value(connection.url)); + url.searchParams.set("sslmode", "verify-full"); + return Redacted.make(url.toString()); + }), + ), +); +``` + +Provide `AWS.DSQL.ConnectHttp` on the Lambda Effect. It grants cluster-scoped `dsql:DbConnect`, not `dsql:DbConnectAdmin`, and signs a fresh IAM token locally when the connection effect runs. The binding does not create the database role or SQL grants. + +`Drizzle.Postgres` resolves the redacted URL on the first query and scopes its pool to the invocation, rather than retaining a pool with an old token across warm invocations. The [complete handler](https://github.com/alchemy-run/alchemy/blob/main/examples/aws-dsql-drizzle/src/Api.ts) uses `build: { install: ["pg"] }` to package the CommonJS driver intact. [Connection lifecycle](/sql/effect-sql/lifecycle). + +## Query with Drizzle + +```typescript +const rows = yield* db.select().from(Todos); + +const [created] = yield* db.insert(Todos).values({ + id: "aaaaaaaa-0000-4000-8000-000000000001", + text: "Ship the DSQL guide", +}).returning(); +``` + +[Drizzle query API](/sql/drizzle/postgres) + +## Deploy + +```sh +pnpm deploy --profile testing +``` + +The example's Action performs the initial bootstrap. Neither `AWS.DSQL.Cluster` nor the runtime `Drizzle.Postgres` wrapper applies generated migration files automatically. + +## Verify the connection + +```sh +aws lambda invoke --region us-west-2 \ + --function-name '' \ + --cli-binary-format raw-in-base64-out \ + --payload file://health-event.json response.json +``` + +Use the printed function name. The Function URL requires [SigV4-signed requests](https://github.com/alchemy-run/alchemy/blob/main/examples/aws-dsql-drizzle/test/integ.test.ts). + +```sh +ALCHEMY_PROFILE=testing bun test test/integ.test.ts +``` + +## Evolve the schema + +Initial setup does not alter existing tables. The Action's `version` input is not a migration history: before adopting generated migrations for this bootstrapped database, establish a reviewed baseline matching `app.todos` rather than replaying its initial creation. + +### Configure SQL generation + +```sh +pnpm add -D drizzle-kit@1.0.0-rc.5-ab785fc +``` + +```typescript +// drizzle.config.ts +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "postgresql", + schema: "./src/schema.ts", + out: "./migrations", +}); +``` + +This generates PostgreSQL SQL from local snapshots without connecting to DSQL; it is not a DSQL-specific dialect or migration runner. + +### Generate after a schema change + +```sh +pnpm exec drizzle-kit generate --config=drizzle.config.ts +``` + +Run whenever `src/schema.ts` changes, before deployment. Do not generate SQL in a deploy hook. + +### Check DSQL compatibility + +DSQL allows [one DDL statement per transaction and separates DDL from DML](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/working-with-postgresql-compatibility-migration-guide.html#working-with-postgresql-compatibility-unsupported-limitations). Do not assume the ordinary PostgreSQL `drizzle-kit migrate` transaction, which also writes migration history, works on DSQL. Review generated SQL for supported syntax, including [`CREATE INDEX ASYNC`](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/create-index-syntax-support.html) and its completion checks. + +### Review and commit the migration + +```sh +git add src/schema.ts drizzle.config.ts migrations/ +git diff --cached -- src/schema.ts drizzle.config.ts migrations/ +git commit -m "feat(db): update DSQL schema and migration" +``` + +Review the staged SQL and snapshots before committing, including any DSQL-specific adjustments or destructive operations. Commit the entire generated migration directory with the schema. The release process consumes committed SQL; deployment must not generate or silently rewrite it. + +### Authenticate the migration operator + +```sh +: "${PGHOST:?Set PGHOST to the deployed DSQL endpoint}" +: "${AWS_REGION:?Set AWS_REGION to the cluster region}" +export PGHOST +export PGPORT=5432 PGDATABASE=postgres PGUSER=admin +export PGSSLMODE=verify-full PGSSLROOTCERT=system +export PGPASSWORD="$(aws dsql generate-db-connect-admin-auth-token \ + --hostname "$PGHOST" --region "$AWS_REGION")" +``` + +This operator connection uses AWS CLI credentials authorized for `dsql:DbConnectAdmin` and a libpq 16-or-newer client for system certificate trust. It is separate from the Lambda's `app_user` connection. Keep the token out of source control and logs; mint a fresh one before reconnecting and unset `PGPASSWORD` when finished. + +### Apply the committed SQL + +Use a validated DSQL-compatible migration runner in the release process before deploying dependent application code. It must record applied versions, handle partial failures, execute DDL as separate autocommit statements, and wait for asynchronous indexes. A plain `psql --file` has no migration history or safe replay; do not wrap the file in `BEGIN` or `--single-transaction`. + +Keep an existing validated DSQL migration workflow separate from `alchemy deploy`; the example's bootstrap Action is not its replacement. Do not expose schema administration through an HTTP `/setup` route. + +## Clean up + +```sh +pnpm destroy --profile testing +aws dsql get-cluster --region us-west-2 --identifier '' +``` + +Deletion is asynchronous. Wait for `DELETED` or `ResourceNotFoundException`. + +## Where next + +[Frontend + RPC](/aws/frontend/full-stack-tanstack-rpc-drizzle) · [Aurora PostgreSQL](/aws/data/drizzle-aurora) · [SQL databases](/sql/databases) diff --git a/.agents/skills/alchemy/references/aws/data/dynamodb.md b/.agents/skills/alchemy/references/aws/data/dynamodb.md new file mode 100644 index 00000000..cffd1a6e --- /dev/null +++ b/.agents/skills/alchemy/references/aws/data/dynamodb.md @@ -0,0 +1,201 @@ + + +# DynamoDB + +> Add a DynamoDB Table, bind GetItem and PutItem to your Lambda, and serve a typed key/value HTTP API backed by DynamoDB. + +**DynamoDB** is AWS's serverless key/value store — structured +records looked up by partition (and optional sort) key, with no +capacity to manage. In Alchemy it's the `Table` resource plus a +binding per operation: `GetItem`, `PutItem`, `UpdateItem`, +`DeleteItem`, `Query`, `Scan`, and the batch/transact variants. +Each binding attaches a least-privilege IAM statement scoped to +the table's ARN. Tables are also an event source: enable +**DynamoDB Streams** and consume change records in a Lambda with +`DynamoDB.consumeTableChanges` (see the +[DynamoDB Streams guide](/aws/messaging/dynamodb-streams)). + +The walkthrough below builds on the [Lambda](/aws/compute/lambda) and +[S3](/aws/data/s3) pages: it adds a **Table** and binds `GetItem` + +`PutItem` to the function — same pattern as S3, different +storage. + +## Define the table + +DynamoDB tables need a partition key (and optionally a sort key) +declared up-front. Yield the `Table` resource in the function's +constructor, mirroring how you added the bucket: + +```diff lang="typescript" +// src/api.ts +import * as AWS from "alchemy/AWS"; ++import * as DynamoDB from "alchemy/AWS/DynamoDB"; +import * as S3 from "alchemy/AWS/S3"; +import { Stack } from "alchemy/Stack"; +import * as Effect from "effect/Effect"; +// ... + +Effect.gen(function* () { + const bucket = yield* S3.Bucket("Blobs"); ++ const table = yield* DynamoDB.Table("Items", { ++ partitionKey: "id", ++ attributes: { ++ id: "S", ++ }, ++ }); + + // ... existing bindings + handler ... +}); +``` + +`attributes` only needs to list the attributes used by keys (and +secondary indexes) — DynamoDB itself is schemaless on the rest of +the row. `"S"` is the AWS shorthand for "string"; `"N"` is number +and `"B"` is binary. + +## Bind GetItem and PutItem + +Same shape as the S3 bindings — `PutItem(table)` returns a callable +Effect and quietly attaches a least-privilege IAM policy scoped to +this table's ARN: + +```diff lang="typescript" +Effect.gen(function* () { + const bucket = yield* S3.Bucket("Blobs"); + const table = yield* DynamoDB.Table("Items", { /* ... */ }); + + const putObject = yield* S3.PutObject(bucket); + const getObject = yield* S3.GetObject(bucket); ++ const putItem = yield* DynamoDB.PutItem(table); ++ const getItem = yield* DynamoDB.GetItem(table); + + return { fetch: /* ... */ }; +}); +``` + +## Write items with `PUT /items/:id` + +Add a branch to `fetch` that writes a row keyed by the URL +segment. DynamoDB takes attribute values in their native typed +form — `{ S: "..." }` for strings, `{ N: "42" }` for numbers — +and Alchemy bindings deliberately **don't** auto-marshal so you +stay on the raw SDK shape: + +```diff lang="typescript" +fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const url = new URL(request.url); + const path = url.pathname; + ++ if (path.startsWith("/items/") && request.method === "PUT") { ++ const id = path.slice("/items/".length); ++ const content = yield* request.text; ++ yield* putItem({ ++ Item: { ++ id: { S: id }, ++ content: { S: content }, ++ }, ++ }); ++ return HttpServerResponse.empty({ status: 204 }); ++ } + + // ... existing /:key blob routes ... +}).pipe(Effect.orDie), +``` + +Skipping marshalling keeps the binding tiny and avoids a +debate over which marshalling layer to use. If you want a +typed-object API across your codebase, build it as a thin +wrapper around `putItem` — the binding won't fight you. + +## Read items with `GET /items/:id` + +The read path uses `getItem` with the same `{ S: id }` key +shape: + +```diff lang="typescript" +if (path.startsWith("/items/") && request.method === "PUT") { + // ... unchanged ... +} + ++if (path.startsWith("/items/") && request.method === "GET") { ++ const id = path.slice("/items/".length); ++ const result = yield* getItem({ Key: { id: { S: id } } }); ++ if (!result.Item) { ++ return HttpServerResponse.text("Not found", { status: 404 }); ++ } ++ return yield* HttpServerResponse.json({ ++ id: result.Item.id?.S, ++ content: result.Item.content?.S, ++ }); ++} +``` + +`getItem` returns `result.Item` as `undefined` when the row +doesn't exist — no need for a typed `catchTag` like S3's +`NoSuchKey`. Map the AWS attribute-value shape back to a plain +object at the response boundary. + +## Provide the runtime layers + +Add the DynamoDB lives next to the S3 ones: + +```diff lang="typescript" +}).pipe( + Effect.provide( + Layer.mergeAll( + AWS.Lambda.BucketEventSource, ++ DynamoDB.GetItemHttp, ++ DynamoDB.PutItemHttp, + S3.PutObjectHttp, + S3.GetObjectHttp, + ), + ), +), +``` + +## Deploy and verify + +```sh +bun alchemy deploy +``` + +Add a round-trip test that exercises the new routes: + +```typescript +// test/integ.test.ts +test( + "DynamoDB round-trip", + Effect.gen(function* () { + const { url } = yield* stack; + + yield* HttpClient.put(`${url}/items/abc`, { + body: HttpBody.text("hello dynamo"), + }); + + const get = yield* HttpClient.get(`${url}/items/abc`); + expect(yield* get.json).toEqual({ id: "abc", content: "hello dynamo" }); + }), +); +``` + +```sh +bun test test/integ.test.ts +``` + +The IAM policy attached to your function role now includes +`dynamodb:GetItem` and `dynamodb:PutItem` — both scoped to this +specific table — alongside the S3 grants. + +## Where next + +- [Process DynamoDB Streams](/aws/messaging/dynamodb-streams) — + fan table changes out into a Lambda-side `Stream` you can + react to. +- [SQS](/aws/messaging/sqs) — a general-purpose queue for background + work, fed from the same Lambda. +- [`Table` reference](/providers/aws/dynamodb/table) — every + prop and attribute, including secondary indexes, TTL, and + point-in-time recovery. diff --git a/.agents/skills/alchemy/references/aws/data/rds.md b/.agents/skills/alchemy/references/aws/data/rds.md new file mode 100644 index 00000000..ae2b0667 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/data/rds.md @@ -0,0 +1,337 @@ + + +# RDS & Aurora + +> Stand up an Aurora cluster in one call with the Aurora helper, connect from Lambda over the Connect binding with pg, or skip connections entirely with the Data API. + +Aurora is AWS's managed Postgres/MySQL. Bringing it up by hand +means a secret, a subnet group, a cluster, instances, and +optionally a proxy — alchemy wraps all of that in the +**`Aurora` helper**, one call that returns every underlying +`DB*` resource so you can drop down to the canonical surface +(`DBCluster`, `DBInstance`, `DBProxy`, …) whenever you outgrow +the defaults. + +At runtime there are two ways in: the **`Connect` binding**, +which resolves host, port, and credentials from Secrets Manager +so you can use a normal Postgres driver like `pg`, and the +**Data API** (`AWS.RDSData.*`), which executes SQL over an HTTPS +endpoint with no database connection at all. + +## Network prerequisite + +Aurora lives inside a VPC. If you don't have one, the +[`Network` helper](/aws/networking) builds the standard +public/private layout in one call; two security groups let the +function reach the database on the Postgres port and nothing +else: + +```typescript +const network = yield* AWS.EC2.Network("Network", { + cidrBlock: "10.0.0.0/16", + availabilityZones: 2, + nat: "single", +}); + +const functionSecurityGroup = yield* AWS.EC2.SecurityGroup( + "FunctionSecurityGroup", + { + vpcId: network.vpcId, + description: "Security group for the service function", + }, +); + +const databaseSecurityGroup = yield* AWS.EC2.SecurityGroup( + "DatabaseSecurityGroup", + { + vpcId: network.vpcId, + description: "Security group for the Aurora cluster", + ingress: [ + { + ipProtocol: "tcp", + fromPort: 5432, + toPort: 5432, + referencedGroupId: functionSecurityGroup.groupId, + description: "Allow the function to reach Aurora PostgreSQL", + }, + ], + }, +); +``` + +See [VPC & networking](/aws/networking) for what `Network` +creates and how to build the same layout by hand. + +## The Aurora helper + +[`Aurora`](/providers/aws/rds/aurora) needs only the subnets and +security groups the database should live in: + +```typescript +const db = yield* AWS.RDS.Aurora("Database", { + subnetIds: network.privateSubnetIds, + securityGroupIds: [databaseSecurityGroup.groupId], +}); +``` + +That one call creates: + +- a **Secrets Manager secret** with a generated 32-character + password for the admin user (`app` by default), +- a **DB subnet group** over the subnets you passed, +- an **Aurora PostgreSQL cluster** (`aurora-postgresql`) with a + database named `app`, storage snapshots tagged, and the + **Data API enabled** (`dataApi: false` to turn it off), +- one **Serverless v2 writer** instance scaling between 0.5 and + 1 ACU. + +The result exposes every underlying resource — `db.secret`, +`db.subnetGroup`, `db.cluster`, `db.writer`, `db.readers`, +and `db.proxy` when enabled — so expanding into the low-level +`AWS.RDS.DB*` surface never means rewriting the stack. + +## Readers, scaling, and a proxy + +Grow the same call as needs grow: + +```typescript +const db = yield* AWS.RDS.Aurora("Database", { + subnetIds: network.privateSubnetIds, + securityGroupIds: [databaseSecurityGroup.groupId], + readers: 2, + proxy: true, + scaling: { minCapacity: 0.5, maxCapacity: 4 }, +}); +``` + +- `readers` adds read replicas alongside the writer, with + promotion tiers set so failover order is deterministic. +- `proxy: true` wires up an RDS Proxy — the IAM role that reads + the secret, the proxy itself (TLS required by default), and a + target group pointed at the cluster. Pass an object instead of + `true` to tune auth, the target group, or add an extra + endpoint. +- `scaling` sets the Serverless v2 ACU range; for provisioned + instances set `instanceClass: "db.r6g.large"` instead of the + default `db.serverless`. + +## Connect from Lambda with pg + +The [`Connect`](/providers/aws/rds/connect) binding resolves +connection settings — host, port, database, username, password — +for a cluster, proxy, or proxy endpoint. Credentials are read +from Secrets Manager **at request time**, so they never land in +environment variables, and the binding attaches the +`secretsmanager:GetSecretValue` IAM statement to the function +automatically. + +Wrap it in an Effect service so the rest of your code depends on +`query`, not on Aurora: + +```typescript +// src/database.ts +import * as AWS from "alchemy/AWS"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { Client } from "pg"; +import { Network } from "./network.ts"; + +export class SqlError extends Data.TaggedError("SqlError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export class Database extends Context.Service< + Database, + { + query( + statement: string, + ): Effect.Effect>, SqlError>; + } +>()("Database") {} + +export const DatabaseAurora = Layer.effect( + Database, + Effect.gen(function* () { + const { vpc, databaseSecurityGroup } = yield* Network; + + const db = yield* AWS.RDS.Aurora("Database", { + subnetIds: vpc.privateSubnetIds, + securityGroupIds: [databaseSecurityGroup.groupId], + }); + + const connect = yield* AWS.RDS.Connect(db.cluster, { + secret: db.secret, + database: "app", + }); + + return Database.of({ + query: (statement) => + connect.pipe( + Effect.catch((cause) => + Effect.fail( + new SqlError({ message: "Failed to resolve connection", cause }), + ), + ), + Effect.flatMap((connection) => + Effect.tryPromise({ + try: async () => { + const client = new Client({ + host: connection.host, + port: connection.port, + database: connection.database, + user: connection.username, + password: connection.password, + ssl: connection.ssl + ? { rejectUnauthorized: false } + : undefined, + }); + await client.connect(); + try { + return (await client.query(statement)).rows; + } finally { + await client.end(); + } + }, + catch: (cause) => + new SqlError({ message: `Failed to execute: ${statement}`, cause }), + }), + ), + ), + }); + }), +); +``` + +`yield* AWS.RDS.Connect(db.cluster, ...)` runs in the function's +Construction phase and returns an inner Effect; each `yield*` of that +inner Effect at runtime resolves fresh `ConnectionInfo` from the +secret. `Network` here is the same network from the prerequisite +section, wrapped in its own layer (see the +[`examples/aws-rds`](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-rds) +for the full three-file layout). + +The Lambda function provides both layers plus the +`AWS.RDS.ConnectHttp` implementation and serves queries over +HTTP: + +```typescript +// src/api.ts +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; +import { Database, DatabaseAurora } from "./database.ts"; +import { NetworkLive } from "./network.ts"; + +export default class Api extends AWS.Lambda.Function()( + "Api", + { main: import.meta.url, runtime: "nodejs24.x" }, + Effect.gen(function* () { + const db = yield* Database; + + return { + fetch: db.query("select now()::text as now").pipe( + Effect.flatMap((rows) => HttpServerResponse.json(rows[0] ?? null)), + Effect.catchTag("SqlError", (error) => + HttpServerResponse.json({ error: error.message }, { status: 500 }), + ), + ), + }; + }).pipe( + Effect.provide( + Layer.provideMerge( + Layer.mergeAll(DatabaseAurora), + Layer.mergeAll(NetworkLive, AWS.RDS.ConnectHttp), + ), + ), + ), +) {} +``` + +The handler only sees `Database` — swap `DatabaseAurora` for a +layer backed by [Neon](/neon) or a local Postgres and nothing +above it changes. + +Yield the function in your Stack as usual: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; +import Api from "./src/api.ts"; + +export default Alchemy.Stack( + "MyApp", + { providers: AWS.providers(), state: AWS.state() }, + Effect.gen(function* () { + const api = yield* Api; + return { url: api.functionUrl }; + }), +); +``` + +To reach a private cluster over a direct connection, the Lambda +function also accepts a `vpc` prop +(`vpc: { subnetIds, securityGroupIds }`) that attaches it to the +VPC and adds the `AWSLambdaVPCAccessExecutionRole` managed +policy for you. + +## The Data API + +Aurora's Data API executes SQL over an HTTPS endpoint — no +connection pool, no driver. The `Aurora` helper enables it by +default, and the `AWS.RDSData` bindings expose it the same way +as any other capability: + +```typescript +// Construction +const execute = yield* AWS.RDSData.ExecuteStatement(db.cluster, { + secret: db.secret, + database: "app", +}); + +// runtime +const result = yield* execute({ sql: "select now()" }); +``` + +Provide `AWS.RDSData.ExecuteStatementHttp` as a layer on the +function. The binding attaches both the +`rds-data:ExecuteStatement` and secret-read IAM statements — +no policy JSON to write. + +Transactions and batches follow the same shape: +`BatchExecuteStatement`, `BeginTransaction`, `CommitTransaction`, +and `RollbackTransaction`, each with a matching `*Http` layer. + +## Where next + +- [Drizzle + Aurora PostgreSQL](/aws/data/drizzle-aurora) — a standalone Lambda + application with IAM database authentication, verified TLS, and controlled schema setup. +- [Drizzle + Aurora DSQL](/aws/data/drizzle-dsql) — a different database with a + public IAM-authenticated endpoint, no Aurora PostgreSQL cluster or VPC required. + +- [SQL databases](/sql/databases) — database engines, connection paths, and client choices. + +- [VPC & networking](/aws/networking) — the `Network` helper and + the primitives underneath it. +- [Lambda](/aws/compute/lambda) — the runtime this page binds Aurora + into. +- [Secrets & env](/aws/security/secrets-env) — how the generated secret + and GetSecretValue binding this page relies on actually work. +- [Neon](/neon) — serverless Postgres with branching, no VPC + required. +- [PlanetScale](/planetscale) — horizontally sharded MySQL and + Postgres. + +Reference: + +- [`Aurora` reference](/providers/aws/rds/aurora) — every prop + on the helper. +- [`Connect` reference](/providers/aws/rds/connect) +- [`ExecuteStatement` reference](/providers/aws/rdsdata/executestatement) diff --git a/.agents/skills/alchemy/references/aws/data/s3.md b/.agents/skills/alchemy/references/aws/data/s3.md new file mode 100644 index 00000000..7f7bbbd3 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/data/s3.md @@ -0,0 +1,311 @@ + + +# S3 + +> Add an S3 Bucket to your Stack, bind PutObject and GetObject as runtime capabilities, and let Alchemy mint the IAM policy for you. + +**S3** is AWS's object store — blobs of any size, addressed by +key. In Alchemy it's the `Bucket` resource plus a binding per S3 +operation: `GetObject`, `PutObject`, `DeleteObject`, +`HeadObject`, `CopyObject`, `ListObjectsV2`, and the multipart +upload family. Each binding you call attaches the matching IAM +statement — scoped to that bucket's ARN — to your function's +role. Buckets are also an event source: subscribe a Lambda to +new-object notifications with `S3.consumeBucketEvents` (see the +[S3 events guide](/aws/messaging/s3-events)). + +The walkthrough below builds on the function from +[Lambda](/aws/compute/lambda): it adds a **Bucket** for blob storage and +binds **PutObject** + **GetObject** — Alchemy mints a +least-privilege IAM policy from the bindings you actually use. + +## Add the bucket + +S3 buckets are canonical Alchemy resources, so you create them +the same way as the Lambda — yield the resource inside an Effect +and the Stack captures it. Open `src/api.ts` and add a bucket in +the constructor: + +```diff lang="typescript" +// src/api.ts +import * as AWS from "alchemy/AWS"; ++import * as S3 from "alchemy/AWS/S3"; +import { Stack } from "alchemy/Stack"; +import * as Effect from "effect/Effect"; ++import { HttpServerRequest } from "effect/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; + +export default class Api extends AWS.Lambda.Function()( + "Api", + Stack.useSync((stack) => ({ + main: import.meta.url, + functionUrl: true, + memory: stack.stage === "prod" ? 1024 : 512, + })), + Effect.gen(function* () { ++ const bucket = yield* S3.Bucket("Blobs"); + + return { + fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), + }; + }), +) {} +``` + +`yield* S3.Bucket("Blobs")` registers the resource with the Stack +under the logical id `Blobs` and returns a typed `Bucket` handle. +Other resources — including this Lambda — can use that handle to +bind operations or grant access. + +## Bind PutObject and GetObject + +S3 operations like `s3:PutObject` and `s3:GetObject` are exposed +as **bindings**. A binding is two things: + +1. A typed runtime function you call from your handler. +2. An IAM policy statement that gets attached to the function + role automatically — scoped to the exact bucket ARN. + +Bind them in the constructor, alongside the bucket: + +```diff lang="typescript" +Effect.gen(function* () { + const bucket = yield* S3.Bucket("Blobs"); ++ const putObject = yield* S3.PutObject(bucket); ++ const getObject = yield* S3.GetObject(bucket); + + return { + fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), + }; +}); +``` + +Both capability calls return a callable Effect: `putObject` +takes a `PutObjectRequest` (the AWS SDK shape, minus the `Bucket` +field Alchemy fills in for you) and `getObject` takes a +`GetObjectRequest`. + +### One binding, one IAM statement + +Each `S3.PutObject(bucket)`-style capability call is **1:1 with an IAM policy statement** +attached to your function's execution role. The mapping is +deterministic: + +| Code | IAM `Action` | IAM `Resource` | +| --------------------------------- | ---------------- | ------------------------ | +| `S3.PutObject(bucket)` | `s3:PutObject` | `bucket.bucketArn` | +| `S3.GetObject(bucket)` | `s3:GetObject` | `bucket.bucketArn/*` | +| `DynamoDB.PutItem(table)` | `dynamodb:PutItem` | `table.tableArn` | +| `SQS.SendMessage(queue)` | `sqs:SendMessage` | `queue.queueArn` | + +So the two capability calls above produce exactly two statements on +the role: + +```json +[ + { + "Sid": "AWSS3PutObjectBlobs", + "Effect": "Allow", + "Action": ["s3:PutObject"], + "Resource": "arn:aws:s3:::myapp-prod-blobs/*" + }, + { + "Sid": "AWSS3GetObjectBlobs", + "Effect": "Allow", + "Action": ["s3:GetObject"], + "Resource": "arn:aws:s3:::myapp-prod-blobs/*" + } +] +``` + +The role is generated, never `"*"`-scoped, and regenerated from +the call sites on every deploy — delete a capability call and the +matching statement disappears next deploy. **The code is the +policy.** + +## Write objects with `PUT /:key` + +Replace the static handler with one that takes the URL path as +the object key and writes the request body to the bucket: + +```diff lang="typescript" +return { +- fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), ++ fetch: Effect.gen(function* () { ++ const request = yield* HttpServerRequest; ++ const key = new URL(request.url).pathname.slice(1); ++ ++ if (request.method === "PUT") { ++ const body = yield* request.text; ++ yield* putObject({ Key: key, Body: body }); ++ return HttpServerResponse.empty({ status: 204 }); ++ } ++ ++ return HttpServerResponse.text("Method not allowed", { status: 405 }); ++ }), +}; +``` + +`putObject({ Key, Body })` is the same shape as +`s3:PutObject` in the AWS SDK, minus the `Bucket` field — Alchemy +fills that in from the bucket you bound, so the call site stays +focused on what's actually variable. + +## Read objects with `GET /:key` + +Add a `GET` branch that fetches the object and pipes it back to +the client: + +```diff lang="typescript" +if (request.method === "PUT") { + const body = yield* request.text; + yield* putObject({ Key: key, Body: body }); + return HttpServerResponse.empty({ status: 204 }); +} + ++if (request.method === "GET") { ++ const result = yield* getObject({ Key: key }); ++ return HttpServerResponse.stream(result.Body!); ++} + +return HttpServerResponse.text("Method not allowed", { status: 405 }); +``` + +`result.Body` is an Effect `Stream`, not a buffered +`Buffer`. `HttpServerResponse.stream` flushes each chunk to the +HTTP response as it arrives, so even a multi-gigabyte object +moves through the function without ever being held in memory. + +## Handle missing objects + +If the key doesn't exist, the AWS SDK throws `NoSuchKey`. The +binding surfaces that as a typed error tag on the Effect, so +`Effect.catchTag` recovers from it without inspecting strings: + +```diff lang="typescript" +if (request.method === "GET") { +- const result = yield* getObject({ Key: key }); ++ const result = yield* getObject({ Key: key }).pipe( ++ Effect.catchTag("NoSuchKey", () => Effect.succeed(undefined)), ++ ); ++ if (!result?.Body) { ++ return HttpServerResponse.text("Not found", { status: 404 }); ++ } + return HttpServerResponse.stream(result.Body!); +} +``` + +Every binding's failure channel is enumerated this way: the AWS +SDK error names become Effect tags, so the type-checker tells +you which failures the call site needs to handle. + +## Convert unhandled errors to 500s + +Any failure you didn't `catchTag` is a programmer error — a +missing IAM grant, a transient AWS outage, a bug. Surface those +as 500s by applying `Effect.orDie` once at the request boundary, +rather than per call: + +```diff lang="typescript" +fetch: Effect.gen(function* () { + // ... PUT and GET branches ... + return HttpServerResponse.text("Method not allowed", { status: 405 }); +-}), ++}).pipe(Effect.orDie), +``` + +Applying `orDie` once at the outer layer keeps the inner code +free of repetitive error plumbing — the only errors you handle +explicitly are the ones you actually have a recovery for +(`NoSuchKey`, in this case). + +## Provide the runtime layers + +Bindings declare a capability; **runtime layers** implement it. +For S3 those are `S3.PutObjectHttp` and `S3.GetObjectHttp` — both +ship in `alchemy/AWS/S3` and depend only on the AWS SDK and the +ambient credentials. Provide them at the bottom of the function: + +```diff lang="typescript" +// src/api.ts ++import * as Layer from "effect/Layer"; +// ... +export default class Api extends AWS.Lambda.Function()( + "Api", + /* ... props ... */, + Effect.gen(function* () { + /* ... bindings + fetch ... */ +- }), ++ }).pipe( ++ Effect.provide(Layer.mergeAll(S3.PutObjectHttp, S3.GetObjectHttp)), ++ ), +) {} +``` + +`Layer.mergeAll` unions multiple layers into one — no order +required — and `Effect.provide` satisfies the binding requirements +declared by `PutObject` and `GetObject`. + +## Deploy + +```sh +bun alchemy deploy +``` + +Alchemy creates the bucket if it doesn't exist, attaches the +generated IAM policy to your Lambda's execution role, and +redeploys the function bundle. + +## Verify + +```typescript +// test/integ.test.ts +import * as AWS from "alchemy/AWS"; +import * as Test from "alchemy/Test/Bun"; +import { expect } from "bun:test"; +import * as Effect from "effect/Effect"; +import * as HttpBody from "effect/http/HttpBody"; +import * as HttpClient from "effect/http/HttpClient"; +import Stack from "../alchemy.run.ts"; + +const { test, beforeAll, deploy } = Test.make({ + providers: AWS.providers(), + state: AWS.state(), +}); + +const stack = beforeAll(deploy(Stack)); + +test( + "S3 round-trip", + Effect.gen(function* () { + const { url } = yield* stack; + + const put = yield* HttpClient.put(`${url}/hello.txt`, { + body: HttpBody.text("world"), + }); + expect(put.status).toBe(204); + + const get = yield* HttpClient.get(`${url}/hello.txt`); + expect(yield* get.text).toBe("world"); + }), +); +``` + +```sh +bun test test/integ.test.ts +``` + +You now have a Lambda that owns a bucket, with no IAM JSON in +sight. + +## Where next + +- [React to S3 events](/aws/messaging/s3-events) — notify the same + function whenever a new object lands in the bucket, wired + through the same binding pipeline. +- [DynamoDB](/aws/data/dynamodb) — structured records you can look up + by key, alongside the blobs. +- [`Bucket` reference](/providers/aws/s3/bucket) — every prop + and attribute. diff --git a/.agents/skills/alchemy/references/aws/email/receiving.md b/.agents/skills/alchemy/references/aws/email/receiving.md new file mode 100644 index 00000000..3460456f --- /dev/null +++ b/.agents/skills/alchemy/references/aws/email/receiving.md @@ -0,0 +1,268 @@ + + +# Email receiving + +> Receive inbound email with SES — rule sets and rules that store mail in S3, fan out to SNS, invoke a Lambda, or bounce it, plus IP filters and the active rule set pointer. + +**SES email receiving** turns a domain you've verified into an +inbound mail endpoint. Mail arriving at any address on the domain +is matched against an ordered list of **receipt rules**, and each +rule runs a list of **actions**: store the raw message in S3, +publish a notification to SNS, invoke a Lambda, bounce it back to +the sender, add a header, or stop processing. + +The receiving primitives: + +- A **[`ReceiptRuleSet`](/providers/aws/ses/receiptruleset)** is + the ordered container for rules. An account can hold many, but + only one is active at a time. +- A **[`ReceiptRule`](/providers/aws/ses/receiptrule)** matches + inbound mail by recipient and applies an ordered list of + actions. +- The + **[`ActiveReceiptRuleSet`](/providers/aws/ses/activereceiptruleset)** + is the single rule set SES actually evaluates — an account + singleton pointer. +- A **[`ReceiptFilter`](/providers/aws/ses/receiptfilter)** + allows or blocks inbound mail by source IP, before rules run. +- **[`SendBounce`](/providers/aws/ses/sendbounce)** is a runtime + binding for bouncing a received message from inside a Lambda. + +:::note +Email receiving is only available in a subset of AWS regions — +today **`us-east-1`**, **`us-west-2`**, and **`eu-west-1`**. +Deploy the stack that owns these resources to one of them, and +point your domain's MX record at that region's SES inbound +endpoint (e.g. `inbound-smtp.us-east-1.amazonaws.com`). +::: + +## Create the Stack + +The snippets below run inside a Stack's `Effect.gen` body: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as SES from "alchemy/AWS/SES"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "InboundMail", + { providers: AWS.providers(), state: AWS.state() }, + Effect.gen(function* () { + // resources go here + return {}; + }), +); +``` + +## Create a rule set + +A rule set starts empty — it's just the ordered container the +rules hang off: + +```typescript +const ruleSet = yield* SES.ReceiptRuleSet("Inbound", {}); +``` + +Omitting `ruleSetName` lets Alchemy generate a deterministic name +from the app, stage, and logical id. Nothing is evaluated against +inbound mail until a rule set is made active (below). + +## Store inbound mail in S3 + +The running example is a support inbox: store every raw message +in a bucket, then notify a topic that new mail arrived. Start with +the S3 action: + +```typescript +const bucket = yield* AWS.S3.Bucket("InboundMail", { + forceDestroy: true, +}); + +const inbound = yield* SES.ReceiptRule("Support", { + ruleSetName: ruleSet.ruleSetName, + recipients: ["support@example.com"], + actions: [ + { + S3Action: { + BucketName: bucket.bucketName, + ObjectKeyPrefix: "support/", + }, + }, + ], +}); +``` + +SES writes the full raw MIME message to the bucket under the +prefix, keyed by message id. The bucket's policy must allow the +SES service principal (`ses.amazonaws.com`) to `s3:PutObject` — SES +validates this when the rule is created, so add it to the bucket +before the rule. + +## Fan out to SNS + +Actions run in array order. Add an `SNSAction` after the S3 action +so SES publishes a notification once the message is stored: + +```typescript +const topic = yield* AWS.SNS.Topic("InboundNotifications", {}); + +const inbound = yield* SES.ReceiptRule("Support", { + ruleSetName: ruleSet.ruleSetName, + recipients: ["support@example.com"], + actions: [ + { + S3Action: { + BucketName: bucket.bucketName, + ObjectKeyPrefix: "support/", + }, + }, + { SNSAction: { TopicArn: topic.topicArn, Encoding: "UTF-8" } }, + ], +}); +``` + +Anything subscribed to the topic — a queue, a Lambda, an +HTTP endpoint — now fans out from the single notification. The +topic's access policy must allow `ses.amazonaws.com` to +`sns:Publish`. (An `SNSAction` publishes a notification, not the +message body; for large messages the S3 copy is the source of +truth.) + +## Invoke a Lambda on inbound mail + +A `LambdaAction` hands the matched message to a function. Point it +at the function's ARN and choose `Event` (async, fire-and-forget) +or `RequestResponse` (synchronous, lets the function influence +delivery): + +```typescript +const rule = yield* SES.ReceiptRule("Process", { + ruleSetName: ruleSet.ruleSetName, + recipients: ["support@example.com"], + actions: [ + { + LambdaAction: { + FunctionArn: processor.functionArn, + InvocationType: "Event", + }, + }, + ], +}); +``` + +See [Lambda](/aws/compute/lambda) for standing up the `processor` +function itself. SES's permission to invoke it is granted the same +way as S3 and SNS — the function's resource policy must allow +`ses.amazonaws.com` to `lambda:InvokeFunction`. + +## Order rules with `after` + +Rules evaluate top to bottom. The `after` prop pins a rule +immediately behind another one in the set. Here a catch-all bounce +runs after the support rule, rejecting mail to unknown recipients: + +```typescript +const bounce = yield* SES.ReceiptRule("BounceUnknown", { + ruleSetName: ruleSet.ruleSetName, + after: inbound.ruleName, + actions: [ + { + BounceAction: { + SmtpReplyCode: "550", + Message: "Mailbox does not exist", + Sender: "mailer-daemon@example.com", + }, + }, + ], +}); +``` + +A rule with no `recipients` matches everything, so ordering it +last makes it a fallthrough. Omit `after` to place a rule at the +top of the set. + +## Activate the rule set + +Rules do nothing until their set is the account's active one. +`ActiveReceiptRuleSet` is the pointer SES evaluates against every +inbound message: + +```typescript +yield* SES.ActiveReceiptRuleSet("Active", { + ruleSetName: ruleSet.ruleSetName, +}); +``` + +Only one rule set is active per account and region. Re-pointing +this resource at a different set switches receiving over in place. +Destroying it deactivates receiving — but only if the account is +still pointed at the set this resource activated; if something else +has since taken over, the destroy is a no-op. + +## Filter senders by IP + +`ReceiptFilter` is an account-level allow/block list on the source +IP of inbound connections, applied before any rule runs. Block +filters win over allow filters: + +```typescript +yield* SES.ReceiptFilter("BlockBadActors", { + ipFilter: { policy: "Block", cidr: "203.0.113.0/24" }, +}); +``` + +`cidr` takes a single address (`203.0.113.7`) or a range +(`203.0.113.0/24`). Filters have no update API, so changing the +policy or range replaces the filter. + +## Bounce a message from a Lambda + +When a `LambdaAction` invokes your function synchronously, the +function can bounce the message itself. `SES.SendBounce` is a +runtime binding — an account-level operation with no resource to +scope to, so it's bound with no argument and its implementation is +provided as `SES.SendBounceHttp`: + +```typescript +// Construction — inside the Lambda's Effect.gen body +const sendBounce = yield* SES.SendBounce(); +``` + +At runtime, generate the bounce from the id of the message SES +delivered to you: + +```typescript +yield* sendBounce({ + OriginalMessageId: messageId, + BounceSender: "mailer-daemon@example.com", + BouncedRecipientInfoList: [ + { Recipient: "nobody@example.com", BounceType: "DoesNotExist" }, + ], +}); +``` + +Provide the implementation on the function with +`Effect.provide(SES.SendBounceHttp)`. You can only bounce a message +within 24 hours of receiving it, and only mail SES actually +received for you. + +## Where next + +- [Lambda](/aws/compute/lambda) — the function a `LambdaAction` + invokes. +- [S3](/aws/data/s3) — the bucket inbound mail lands in. +- [SNS](/aws/messaging/sns) — fan the notification out to queues + and functions. + +Reference: + +- [ReceiptRuleSet API reference](/providers/aws/ses/receiptruleset) +- [ReceiptRule API reference](/providers/aws/ses/receiptrule) +- [ActiveReceiptRuleSet API reference](/providers/aws/ses/activereceiptruleset) +- [ReceiptFilter API reference](/providers/aws/ses/receiptfilter) +- [SendBounce API reference](/providers/aws/ses/sendbounce) diff --git a/.agents/skills/alchemy/references/aws/email/sending.md b/.agents/skills/alchemy/references/aws/email/sending.md new file mode 100644 index 00000000..86851d1e --- /dev/null +++ b/.agents/skills/alchemy/references/aws/email/sending.md @@ -0,0 +1,410 @@ + + +# Sending & managing email + +> Send email with SES — verify identities, send from a Lambda with the SendEmail binding, manage contact lists, tenants, dedicated IP pools, account settings, and deliverability insights. + +**SES sending** starts with a verified **identity** — a domain or +an email address you own — and a runtime **binding** that lets a +Lambda send through it. Around that core, SES v2 adds the pieces +you need to run email at scale: authorization policies, contact +lists, dedicated IP pools, per-tenant isolation, multi-region +endpoints, account-wide settings, and deliverability insights. + +The sending surface: + +- An **[`EmailIdentity`](/providers/aws/ses/emailidentity)** is a + verified domain or address, and + **[`SendEmail`](/providers/aws/ses/sendemail)** is the runtime + binding that sends through it. +- An + **[`EmailIdentityPolicy`](/providers/aws/ses/emailidentitypolicy)** + authorizes other accounts to send as your identity. +- A **[`ContactList`](/providers/aws/ses/contactlist)** and its + **[`Contact`](/providers/aws/ses/contact)** entries model a + managed audience with subscription topics. +- A + **[`DedicatedIpPool`](/providers/aws/ses/dedicatedippool)** + isolates sending reputation onto dedicated IPs. +- A **[`Tenant`](/providers/aws/ses/tenant)** groups identities, + configuration sets, and templates for a customer or business + unit. +- A + **[`MultiRegionEndpoint`](/providers/aws/ses/multiregionendpoint)** + splits sending across regions. +- **[`AccountSettings`](/providers/aws/ses/accountsettings)** + manages account-wide sending status, the suppression list, and + Virtual Deliverability Manager. + +:::note +New SES accounts start in the **sandbox**: you can only send to +verified identities or the mailbox simulator +(`success@simulator.amazonses.com`), and volume is capped. Request +production access from the SES console to lift both limits. +::: + +## Create the Stack + +The resource snippets below run inside a Stack's `Effect.gen` +body: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as SES from "alchemy/AWS/SES"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "Mailer", + { providers: AWS.providers(), state: AWS.state() }, + Effect.gen(function* () { + // resources go here + return {}; + }), +); +``` + +## Verify a sending identity + +An identity is a domain or a single address. A domain identity +generates Easy DKIM tokens you publish as CNAME records: + +```typescript +const identity = yield* SES.EmailIdentity("Sender", { + emailIdentity: "mail.example.com", +}); +// publish identity.dkimTokens as CNAME records to finish verifying +``` + +Each token in `identity.dkimTokens` is published as +`{token}._domainkey.mail.example.com CNAME {token}.dkim.amazonses.com`. +For an address identity (`hello@example.com` instead of a domain), +SES emails a verification link and there are no DKIM records to +publish. The identity is usable once `verificationStatus` reaches +`SUCCESS`. + +## Send email from a Lambda + +`SES.SendEmail(identity)` binds sending to that identity and +returns a callable. Provide its implementation with +`SES.SendEmailHttp`, which mints the least-privilege +`ses:SendEmail` policy scoped to the identity: + +```typescript +// src/mailer.ts +import * as AWS from "alchemy/AWS"; +import * as SES from "alchemy/AWS/SES"; +import * as Effect from "effect/Effect"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; + +export default class Mailer extends AWS.Lambda.Function()( + "Mailer", + { main: import.meta.url }, + Effect.gen(function* () { + const identity = yield* SES.EmailIdentity("Sender", { + emailIdentity: "mail.example.com", + }); + const sendEmail = yield* SES.SendEmail(identity); + + return { + fetch: Effect.gen(function* () { + yield* sendEmail({ + FromEmailAddress: "hello@mail.example.com", + Destination: { ToAddresses: ["customer@example.com"] }, + Content: { + Simple: { + Subject: { Data: "Welcome!" }, + Body: { Text: { Data: "Hello from SES." } }, + }, + }, + }); + return yield* HttpServerResponse.json({ ok: true }); + }).pipe(Effect.orDie), + }; + }).pipe(Effect.provide(SES.SendEmailHttp)), +) {} +``` + +For a domain identity, pass `FromEmailAddress` explicitly (any +address at the domain); for an address identity it defaults to the +identity itself. The callable also accepts `Content.Raw` for a +pre-built MIME message and `Content.Template` for templated sends. +To route through a configuration set — for open/click tracking or +event publishing — bind it too: `SES.SendEmail(identity, configSet)`. + +## Authorize another account to send + +An `EmailIdentityPolicy` attaches a sending-authorization policy to +an identity, letting another AWS account or IAM principal send as +it: + +```typescript +const policy = yield* SES.EmailIdentityPolicy("AllowPartner", { + emailIdentity: identity.emailIdentity, + policy: { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { AWS: "arn:aws:iam::111122223333:root" }, + Action: ["ses:SendEmail"], + Resource: identity.identityArn, + }, + ], + }, +}); +``` + +Alchemy serializes this typed IAM policy at the SES API boundary and +compares normalized document content when detecting drift. + +## Build a contact list + +A `ContactList` is a managed audience with subscription topics — +the backbone of SES's list-management and one-click unsubscribe +handling: + +```typescript +const list = yield* SES.ContactList("Newsletter", { + description: "Weekly product newsletter", + topics: [ + { + TopicName: "product-updates", + DisplayName: "Product Updates", + DefaultSubscriptionStatus: "OPT_IN", + }, + { + TopicName: "promotions", + DisplayName: "Promotions", + DefaultSubscriptionStatus: "OPT_OUT", + }, + ], +}); +``` + +`updateContactList` replaces the topic set wholesale, so the full +desired list of topics is sent on every change. Deleting the list +deletes all of its contacts. + +## Add contacts + +A `Contact` is a single address on a list, with its own per-topic +subscription preferences: + +```typescript +const contact = yield* SES.Contact("Subscriber", { + contactListName: list.contactListName, + emailAddress: "reader@example.com", + topicPreferences: [ + { TopicName: "product-updates", SubscriptionStatus: "OPT_IN" }, + { TopicName: "promotions", SubscriptionStatus: "OPT_OUT" }, + ], +}); +``` + +The `(list, emailAddress)` pair is the stable key — changing either +replaces the contact. Set `unsubscribeAll: true` to opt the contact +out of every topic at once. + +## Brand the verification email + +A `CustomVerificationEmailTemplate` is the branded email SES sends +when you verify a new address identity — replacing the plain +default message: + +```typescript +const template = yield* SES.CustomVerificationEmailTemplate("Verify", { + fromEmailAddress: "verify@mail.example.com", + templateSubject: "Please confirm your email", + templateContent: + "Click the link to verify your address.", + successRedirectionURL: "https://example.com/verified", + failureRedirectionURL: "https://example.com/verify-failed", +}); +``` + +Creating and managing the template works on any account. To +actually *send* one, bind `SES.SendCustomVerificationEmail` — an +account-level binding (the template has no ARN to scope to) whose +implementation is `SES.SendCustomVerificationEmailHttp`: + +```typescript +// Construction — inside the Lambda's Effect.gen body +const sendVerification = yield* SES.SendCustomVerificationEmail(); + +// runtime +const { MessageId } = yield* sendVerification({ + EmailAddress: "new-user@example.com", + TemplateName: yield* template.templateName, +}); +``` + +Sending a custom verification email requires production access — +in the sandbox the call fails with the typed `BadRequestException`. + +## Isolate reputation with a dedicated IP pool + +A `DedicatedIpPool` groups dedicated IPs so different kinds of mail +(marketing vs. transactional) build reputation independently: + +```typescript +const pool = yield* SES.DedicatedIpPool("Transactional", { + scalingMode: "MANAGED", +}); +``` + +:::caution +A dedicated IP pool provisions dedicated IP capacity and **starts +billing immediately** — `MANAGED` pools bill for managed dedicated +IP usage as soon as they exist, and `STANDARD` pools bill per +dedicated IP you add. Only create pools you intend to pay for. +::: + +`STANDARD` → `MANAGED` is applied in place; `MANAGED` → `STANDARD` +has no API and replaces the pool. + +## Group sending into tenants + +A `Tenant` isolates a customer or business unit's sending — its own +reputation metrics, sending status, and optional tenant-scoped +suppression list — inside one SES account: + +```typescript +const tenant = yield* SES.Tenant("CustomerA", { + suppression: { reasons: ["BOUNCE", "COMPLAINT"], scope: "TENANT" }, +}); +``` + +Attach identities, configuration sets, or templates to the tenant +with a `TenantResourceAssociation`: + +```typescript +const link = yield* SES.TenantResourceAssociation("SenderLink", { + tenantName: tenant.tenantName, + resourceArn: identity.identityArn, +}); +``` + +A single resource can belong to multiple tenants. Deleting the +tenant removes its associations but leaves the underlying resources +in place. + +## Route sending across regions + +A `MultiRegionEndpoint` (global endpoint) splits sending traffic +across a primary region — where the endpoint is created — and one +or more secondary regions: + +```typescript +const endpoint = yield* SES.MultiRegionEndpoint("Global", { + regions: ["eu-west-1"], +}); +``` + +:::note +Provisioning is asynchronous: creation returns immediately with +status `CREATING`, and the endpoint only becomes usable once it +reaches `READY`. This resource does **not** wait for `READY` — poll +`getMultiRegionEndpoint` yourself if you need to block on readiness. +::: + +There is no update API, so changing the name or routes replaces the +endpoint. + +## Manage account-wide settings + +`AccountSettings` is an account/region singleton for account-wide +sending status, the suppression list, and Virtual Deliverability +Manager (VDM). Only the aspects you specify are managed — omit the +rest to leave them untouched: + +```typescript +const settings = yield* SES.AccountSettings("Account", { + vdm: { + enabled: "ENABLED", + dashboardEngagementMetrics: "ENABLED", + }, + suppression: { reasons: ["BOUNCE", "COMPLAINT"] }, +}); +``` + +:::caution +`sendingEnabled: false` **stops all sending for the entire +account**, not just this stack's mail. Only manage it when you +intend to pause account-wide sending. +::: + +Deleting this resource is a no-op: these are account-global toggles +with no single safe default, so Alchemy leaves them exactly as they +are. Change the props and re-deploy to adjust them. + +## Monitor deliverability + +Enabling VDM (above) unlocks a set of read-only deliverability +bindings you can call from a Lambda. Look up a single message's +per-recipient event timeline with `GetMessageInsights`: + +```typescript +// Construction — account-level binding, no resource argument +const getInsights = yield* SES.GetMessageInsights(); + +// runtime — MessageId returned by a prior SendEmail +const { Insights } = yield* getInsights({ MessageId: messageId }); +``` + +Fetch aggregated metric time-series — sends, deliveries, bounces, +complaints, opens, clicks — with `BatchGetMetricData`: + +```typescript +const getMetrics = yield* SES.BatchGetMetricData(); + +const { Results } = yield* getMetrics({ + Queries: [ + { + Id: "sends", + Namespace: "VDM", + Metric: "SEND", + StartDate: new Date(Date.now() - 7 * 24 * 3600 * 1000), + EndDate: new Date(), + }, + ], +}); +``` + +Provide each with its `*Http` layer (`SES.GetMessageInsightsHttp`, +`SES.BatchGetMetricDataHttp`). Two more account-level bindings round +out the set: `GetBlacklistReports` reports which anti-spam +blacklists your dedicated IPs appear on, and +`GetDomainStatisticsReport` returns inbox-placement statistics for a +domain — the latter requires the SES deliverability dashboard +subscription. + +Some of the SES surface isn't modeled as resources yet: the +deliverability dashboard *subscription* itself, and the dedicated-IP +data plane (assigning specific IPs to a pool and warming them). Use +the console or the AWS SDK for those. + +## Where next + +- [Lambda](/aws/compute/lambda) — the runtime that sends. +- [Email receiving](/aws/email/receiving) — the inbound side: SES + receipt rules into S3, SNS, and Lambda. +- [CloudWatch](/aws/observability/cloudwatch) — alarm on bounce and + complaint metrics. + +Reference: + +- [EmailIdentity API reference](/providers/aws/ses/emailidentity) +- [SendEmail API reference](/providers/aws/ses/sendemail) +- [EmailIdentityPolicy API reference](/providers/aws/ses/emailidentitypolicy) +- [ContactList API reference](/providers/aws/ses/contactlist) · + [Contact API reference](/providers/aws/ses/contact) +- [CustomVerificationEmailTemplate API reference](/providers/aws/ses/customverificationemailtemplate) +- [DedicatedIpPool API reference](/providers/aws/ses/dedicatedippool) +- [Tenant API reference](/providers/aws/ses/tenant) · + [TenantResourceAssociation API reference](/providers/aws/ses/tenantresourceassociation) +- [MultiRegionEndpoint API reference](/providers/aws/ses/multiregionendpoint) +- [AccountSettings API reference](/providers/aws/ses/accountsettings) diff --git a/.agents/skills/alchemy/references/aws/frontend/astro.md b/.agents/skills/alchemy/references/aws/frontend/astro.md new file mode 100644 index 00000000..398c2d39 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/frontend/astro.md @@ -0,0 +1,216 @@ + + +# Astro + +> Deploy an Astro app to AWS with AWS.Website.Astro — SSR on a streaming Lambda Function URL, static output on S3 + CloudFront, and Astro's own dev server under alchemy dev. + +`AWS.Website.Astro` deploys an [Astro](https://astro.build) project +to AWS. It runs Astro's programmatic build with a wrangler-free AWS +Lambda adapter: server-rendered pages stream from a Lambda Function +URL, prerendered pages and client assets are served from a private S3 +bucket through CloudFront, routed at the edge. Your `astro.config.*` +loads natively — there is no adapter to install into it and no +CloudFormation to write. + +## Install + +The build integration is not bundled with alchemy. Install +`@alchemy.run/frontend-frameworks`; the resource loads its `/astro` +and `/astro/aws` exports from your project at deploy time. It is only +used at build time, so a dev dependency is enough: + +```sh +bun add -d @alchemy.run/frontend-frameworks +``` + +## Configure Astro + +Your `astro.config.*` file loads natively — integrations, Vite +plugins, and any other non-serializable options work exactly as they +do outside Alchemy. Alchemy merges a programmatic config *over* it: +the AWS Lambda adapter is injected for you (don't declare an +`adapter` in your config — that fails the build with an actionable +error), and options passed via the `astro` prop override the file's — +see [Astro configuration](#astro-configuration) below for the +details. + +## Declare the Website + +Declare the site as a module-level const (rather than inline in the +Stack): + +```typescript +// alchemy.run.ts +import * as AWS from "alchemy/AWS"; + +export const Website = AWS.Website.Astro("Website"); +``` + +## Add it to the Stack + +Yield the site from your Stack and return its URL: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "MyAstroSite", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { + const site = yield* Website; + return { url: site.url }; + }), +); +``` + +Pages are server-rendered by default. Pages that +`export const prerender = true` are uploaded to S3 and served from +the edge without invoking the server. + +See +[examples/aws-website-astro](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-website-astro) +for the checked-in example. + +## Add environment variables + +The server function's environment is configured under `env` — plain +values, or outputs from other resources in the Stack: + +```diff lang="typescript" +// alchemy.run.ts +export const Website = AWS.Website.Astro("Website", { ++ env: { ++ GREETING: "Hello from Alchemy!", ++ API_BASE: api.url, ++ }, +}); +``` + +The values are set on the Lambda at deploy time and injected into the +dev server's process environment under `alchemy dev`, so server code +reads the same values in both modes. + +## Read the environment in server code + +The Astro server runs in a plain Node Lambda, so server-rendered +pages and endpoints read the environment from `process.env`: + +```astro +--- +// src/pages/index.astro +const greeting = process.env.GREETING ?? "hello"; +--- + +

{greeting}

+``` + +## Fully static sites + +With `astro: { output: "static" }` every page is prerendered at +build time and the deploy is assets-only — no Lambda is created, and +the S3 + CloudFront asset layer answers every request: + +```typescript +export const Website = AWS.Website.Astro("Website", { + astro: { output: "static" }, + errorPage: "404.html", +}); +``` + +`errorPage` serves the built `404.html` for unmatched routes; `spa` +answers misses with the index page (200) instead. + +## Astro configuration + +Your `astro.config.*` is the primary place to configure Astro — +integrations, Vite plugins, and every other option (serializable or +not) work as usual: + +```typescript +// astro.config.ts +import { defineConfig } from "astro/config"; +import react from "@astrojs/react"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + site: "https://blog.example.com", + integrations: [react()], + vite: { + plugins: [tailwindcss()], + }, +}); +``` + +A config file can't consume Alchemy `Output`s or vary by stage. For +those values the `astro` prop is a deploy-time override bag — a +serializable subset (`site`, `base`, `output`, `srcDir`, `publicDir`, +`outDir`, `trailingSlash`) merged *over* the config file, so a value +set here wins: + +```typescript +// alchemy.run.ts +import { Stack } from "alchemy/Stack"; + +Effect.gen(function* () { + const { stage } = yield* Stack; + + const site = yield* AWS.Website.Astro("Website", { + astro: { + site: + stage === "prod" + ? "https://blog.example.com" + : `https://${stage}.example.com`, + }, + }); +}); +``` + +Use `config` to load an alternate config file (relative to +`rootDir`) instead of astro's own `astro.config.*` discovery: + +```typescript +export const Website = AWS.Website.Astro("Website", { + config: "astro.deploy.config.ts", +}); +``` + +Two options are managed for you regardless of the config file: + +- `adapter` — Alchemy injects its wrangler-free AWS Lambda adapter. + Declaring an adapter in `astro.config.*` fails the build with an + actionable error. +- `output` — Alchemy defaults it to `"server"` (superseding a + file-level `output`), so pages render on demand in the Lambda. + Opt into a fully prerendered site with `astro: { output: "static" }` + on the resource. + +## Local dev + +`alchemy dev` runs Astro's own dev server (native HMR) instead of +deploying; `site.url` is the local address and no AWS resources are +created. Wrap the site in `Alchemy.remote()` to deploy the real +infrastructure even during dev. + +## Custom domain + +```typescript +const site = yield* AWS.Website.Astro("Web", { + domain: { name: "app.example.com" }, +}); +``` + +The hosted zone is inferred from the hostname. Pass `hostedZoneId` to +pin it when several zones could match. + +:::note +Astro's default image service uses `sharp`. To use Astro image +optimization on AWS, add `sharp` as a project dependency so it ships +with the server bundle. +::: diff --git a/.agents/skills/alchemy/references/aws/frontend/foldkit.md b/.agents/skills/alchemy/references/aws/frontend/foldkit.md new file mode 100644 index 00000000..5d6442a3 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/frontend/foldkit.md @@ -0,0 +1,239 @@ + + +# Foldkit + +> Deploy a Foldkit app to AWS with AWS.Website.Foldkit — the client build on S3 + CloudFront, SPA deep links by default, and Foldkit's own Vite dev server under alchemy dev. + +[Foldkit](https://foldkit.dev) is an Elm-architecture frontend framework +built on Effect. Its apps are client-only Vite projects — the Foldkit +Vite plugin only adds HMR and devtools wiring — so +`AWS.Website.Foldkit` deploys one with a single declaration: the client +build lands in a private S3 bucket and serves through CloudFront, routed +at the edge. There is no server function, no build command, and no +CloudFormation to write. + +`Foldkit` is [`AWS.Website.Vite`](/aws/frontend/vite) with +Foldkit's defaults applied: client-side routing is assumed, so deep +links fall back to the app shell without you configuring anything. + +## Install + +The build integration is not bundled with alchemy. Install +`@alchemy.run/frontend-frameworks`; the resource loads its `/vite` and +`/vite/aws` exports from your project at deploy time. It is only used at +build time, so a dev dependency is enough: + +```sh +bun add -d @alchemy.run/frontend-frameworks +``` + +## Configure Vite + +Your Vite config stays what Foldkit's setup gives you: + +```typescript +// vite.config.ts +import { foldkit } from "@foldkit/vite-plugin"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [foldkit()], + optimizeDeps: { + entries: ["src/entry.ts"], + }, +}); +``` + +Alchemy runs Vite programmatically on the project root, so the Foldkit +plugin and the rest of your setup are preserved as-is. + +:::note[Pinned Effect versions] +Foldkit pins its `effect` peer dependency to an exact version. Alchemy +runs your app's own Vite build with your app's own `node_modules`, so +that pin is between Foldkit and your package manager — Alchemy imposes +no Effect version on your frontend. +::: + +## Declare the Website + +Declare the site as a module-level const (rather than inline in the +Stack): + +```typescript +// alchemy.run.ts +import * as AWS from "alchemy/AWS"; + +export const Website = AWS.Website.Foldkit("Website"); +``` + +For an app in a subdirectory of a monorepo, point `rootDir` at it: + +```typescript +export const Website = AWS.Website.Foldkit("Website", { + rootDir: "applications/web", +}); +``` + +## Add it to the Stack + +Yield the site from your Stack and return its URL: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "MyFoldkitSite", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { + const site = yield* Website; + return { url: site.url }; + }), +); +``` + +One declaration expands into the whole stack — an S3 bucket, the asset +upload, a CloudFront KeyValueStore holding the file manifest, a +viewer-request CloudFront Function, and the distribution itself. + +```sh +bun alchemy deploy +``` + +## Deep links + +A Foldkit app that uses URL routing (`Runtime.makeApplication` with +`route`, `onUrlRequest`, and `onUrlChange`) resolves routes on the +client, so a deep link like `/counter/42` arrives at the edge as a +request for a file that does not exist. + +`Foldkit` sets `spa` by default, which answers those misses with +`index.html` and a `200` — the Foldkit runtime resolves the route once +the app boots. + +An app that ships a real 404 page opts out with `errorPage`. The two are +mutually exclusive: + +```typescript +export const Website = AWS.Website.Foldkit("Website", { + spa: false, + errorPage: "404.html", +}); +``` + +## Add environment variables + +A Foldkit app has no server, so anything it needs is baked into the +bundle at build time. Vite inlines `VITE_`-prefixed variables from the +environment the build runs in — a `.env` file in the project root, or +the shell that runs `alchemy deploy`: + +```sh +# .env +VITE_API_URL=https://api.example.com +``` + +Type it for your Foldkit code with Vite's standard `ImportMetaEnv` +augmentation: + +```typescript +// src/vite-env.d.ts +/// + +interface ImportMetaEnv { + readonly VITE_API_URL: string; +} +``` + +Client code reads it as `import.meta.env.VITE_API_URL`, e.g. from a +`Command` that fetches your API. + +A value produced by another resource in the Stack (a Function URL, a +table name) is not known when this build runs, and the resource takes no +`env` prop. Either serve it from an +[API](/aws/apis/effect-http-api) the app fetches at runtime, or deploy +with [`AWS.Website.StaticSite`](/aws/frontend/static-site) instead — +it runs the build as a command and passes `environment` to it, so an +Output can be inlined at build time. + +## Deploy-time overrides + +Vite configuration — the Foldkit plugin included — lives in your +project's own `vite.config.*`. The `vite` bag is for deploy-time +overrides merged *over* that file, for values decided by the +deployment (config files can't consume alchemy `Output`s): + +```typescript +export const Website = AWS.Website.Foldkit("Docs", { + vite: { + base: "/docs/", + outDir: "build", + }, +}); +``` + +`base` is the public path the site deploys under (it also applies to +the dev server under `alchemy dev`); `outDir` is the build output +directory, relative to `rootDir`. Leave the bag unset and the resolved +values from your `vite.config.*` are used — Vite's own `outDir` +default is `dist`. `config` selects an alternate config file, relative +to `rootDir`. + +## Local dev + +`alchemy dev` runs the app's own Vite dev server, so Foldkit's HMR with +state preservation and its devtools wiring work unchanged. `site.url` is +the local address and no AWS resources are created. Wrap the site in +`Alchemy.remote()` to deploy the real infrastructure even during dev. + +Pin the dev server's address when several apps run in one Stack: + +```typescript +export const Website = AWS.Website.Foldkit("Website", { + dev: { host: "127.0.0.1", port: 5180, strictPort: true }, +}); +``` + +## Custom domain + +```typescript +// alchemy.run.ts +export const Website = AWS.Website.Foldkit("Website", { + domain: { name: "app.example.com" }, +}); +``` + +The hosted zone is inferred from the hostname. Pass `hostedZoneId` to +pin it when several zones could match. + +Serve several sites under one distribution by pointing `domain.router` +at an existing +[`AWS.Website.Router`](/aws/frontend/static-site#compose-sites-with-a-router). +The site takes the Router's outputs, so both are yielded inside the +Stack generator: + +```typescript +Effect.gen(function* () { + const router = yield* AWS.Website.Router("Router", {}); + + const site = yield* AWS.Website.Foldkit("Website", { + domain: { router, name: "app.example.com" }, + }); + + return { url: site.url }; +}); +``` + +:::note[No edge code] +Cloudflare's `Foldkit` accepts a `main` entry that runs a Worker in +front of the assets. AWS has no equivalent: this composite is +assets-only and never creates a server function. Put request-time logic +behind its own [Lambda API](/aws/apis/effect-http-api) and fetch it from +the client. +::: diff --git a/.agents/skills/alchemy/references/aws/frontend/full-stack-tanstack-rpc-drizzle.md b/.agents/skills/alchemy/references/aws/frontend/full-stack-tanstack-rpc-drizzle.md new file mode 100644 index 00000000..99d79b19 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/frontend/full-stack-tanstack-rpc-drizzle.md @@ -0,0 +1,401 @@ + + +# Full-stack TanStack Start + RPC + Drizzle + +> Build a reactive full-stack app on AWS — a TanStack Start UI on CloudFront and Lambda that drives an Effect RPC Lambda over Drizzle and Aurora DSQL, with browser state wired through Effect 4's native atom RPC. + +[TanStack Start](/aws/frontend/tanstack-start) · [Effect RPC](/aws/apis/effect-rpc) · [Drizzle + DSQL](/aws/data/drizzle-dsql) + +## The shape + +``` +Browser (React) + │ useAtomValue / useAtomSet (src/routes/index.tsx) + ▼ +AtomRpc client (TodoRpcs) (src/rpc-client.ts) + │ HTTP POST /rpc (JSON) + ▼ +TanStack Start Lambda — /rpc proxy (src/routes/rpc.ts) + │ fetch(process.env.BACKEND_URL) + ▼ +Backend RPC Lambda (TodoRpcs) (src/backend/api.ts) + │ Drizzle.Postgres over DSQL.Connect + ▼ +Aurora DSQL cluster +``` + +The browser calls a same-origin `/rpc` route; the frontend Lambda forwards requests to the backend. + +## Install + +```sh +bun add @distilled.cloud/aws @effect/atom-react @effect/sql-pg drizzle-orm effect pg\nbun add -d @alchemy.run/frontend-frameworks +``` + +## 1. The shared RPC contract + +Share the RPC contract between the backend and browser: + +```typescript +// src/backend/rpc.ts +import * as Schema from "effect/Schema"; +import { Rpc, RpcGroup } from "effect/rpc"; + +export class Todo extends Schema.Class("Todo")({ + id: Schema.String, + text: Schema.String, + done: Schema.Boolean, + createdAt: Schema.Date, +}) {} + +export class TodoNotFound extends Schema.TaggedError()( + "TodoNotFound", + { message: Schema.String, id: Schema.String }, +) {} + +export class TodoRpcs extends RpcGroup.make( + Rpc.make("listTodos", { success: Schema.Array(Todo) }), + Rpc.make("createTodo", { payload: { text: Schema.String }, success: Todo }), + Rpc.make("toggleTodo", { + payload: { id: Schema.String, done: Schema.Boolean }, + success: Todo, + error: TodoNotFound, + }), + Rpc.make("deleteTodo", { + payload: { id: Schema.String }, + success: Schema.String, + error: TodoNotFound, + }), +) {} +``` + +[RPC contracts and errors](/aws/apis/effect-rpc) + +## 2. The database + +Copy `database.ts`, `bootstrap.ts`, and `client.ts` from the [DSQL example](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-dsql-drizzle/src) into `src/backend/`. [Database setup](/aws/data/drizzle-dsql). + +## 3. The table + +Copy the example's [`schema.ts`](https://github.com/alchemy-run/alchemy/blob/main/examples/aws-dsql-drizzle/src/schema.ts) into `src/backend/`. It targets `app.todos` with application-generated UUIDs, matching the bootstrap and role grants. Check [DSQL compatibility](/aws/data/drizzle-dsql#define-the-table) before extending it. + +:::note +`AWS.DSQL.Cluster` has no `migrations` prop. The Action in step 5 bootstraps the database without an HTTP `/setup` route; it is not a versioned migrator. Whenever the schema changes, explicitly generate migration files using the [DSQL migration workflow](/aws/data/drizzle-dsql#evolve-the-schema), with schema path `./src/backend/schema.ts`. Stage the schema, config, SQL, and snapshots, review with `git diff --cached`, and commit before release. The migration step consumes committed SQL, not SQL generated during deployment. +::: + +## 4. The backend RPC Lambda + +`connectDatabase` uses the non-admin `app_user` role with verified TLS. `DSQL.Connect` grants cluster-scoped `dsql:DbConnect` and signs IAM tokens locally; Drizzle opens the connection lazily and closes its pool with the invocation scope. + +```typescript +// src/backend/api.ts +import * as AWS from "alchemy/AWS"; +import { eq } from "drizzle-orm"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { RpcSerialization, RpcServer } from "effect/rpc"; +import { connectDatabase } from "./client.ts"; +import { Todo, TodoNotFound, TodoRpcs } from "./rpc.ts"; +import { Todos } from "./schema.ts"; + +export default class Backend extends AWS.Lambda.Function()( + "Backend", + { + main: import.meta.url, + functionUrl: true, + build: { install: ["pg"] }, + }, + Effect.gen(function* () { + const db = yield* connectDatabase; + + const handlers = TodoRpcs.toLayer({ + listTodos: () => + db + .select() + .from(Todos) + .orderBy(Todos.createdAt) + .pipe( + Effect.map((rows) => rows.map((row) => new Todo(row))), + Effect.orDie, + ), + + createTodo: ({ text }) => + db + .insert(Todos) + .values({ + id: crypto.randomUUID(), + text, + done: false, + createdAt: new Date(), + }) + .returning() + .pipe( + Effect.map(([row]) => new Todo(row)), + Effect.orDie, + ), + + toggleTodo: ({ id, done }) => + db + .update(Todos) + .set({ done }) + .where(eq(Todos.id, id)) + .returning() + .pipe( + Effect.orDie, // DB errors -> defects; TodoNotFound below stays a failure + Effect.flatMap(([row]) => + row + ? Effect.succeed(new Todo(row)) + : new TodoNotFound({ message: `Todo ${id} not found`, id }), + ), + ), + + deleteTodo: ({ id }) => + db + .delete(Todos) + .where(eq(Todos.id, id)) + .returning() + .pipe( + Effect.orDie, + Effect.flatMap(([row]) => + row + ? Effect.succeed(row.id) + : new TodoNotFound({ message: `Todo ${id} not found`, id }), + ), + ), + }); + + const rpc = yield* RpcServer.toHttpEffect(TodoRpcs).pipe( + Effect.provide(Layer.mergeAll(handlers, RpcSerialization.layerJson)), + ); + + return { fetch: rpc.pipe(Effect.orDie) }; + }).pipe(Effect.provide(AWS.DSQL.ConnectHttp)), +) {} +``` + +`Effect.orDie` handles database failures before `flatMap`, preserving the typed `TodoNotFound` failure. Client and server must both use `RpcSerialization.layerJson`. + +## 5. Wire it into the Stack + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; +import Backend from "./src/backend/api.ts"; +import { BootstrapDatabase } from "./src/backend/bootstrap.ts"; +import { Database } from "./src/backend/database.ts"; + +export default Alchemy.Stack( + "AwsTanstackRpcDrizzle", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { + const backend = yield* Backend.pipe(Alchemy.remote()); + const cluster = yield* Database; + const schemaVersion = yield* BootstrapDatabase({ + endpoint: cluster.endpoint, + roleArn: backend.roleArn, + version: "1", + }); + + const site = yield* AWS.Website.TanStackStart("Website", { + env: { BACKEND_URL: backend.functionUrl }, + }); + + return { + websiteUrl: site.url, + backendUrl: backend.functionUrl, + schemaVersion, + }; + }), +); +``` + +`BootstrapDatabase` initializes the schema. `Alchemy.remote()` keeps the backend on AWS during local development. + +:::caution +The backend Function URL is public. Add authentication before exposing private data; `AWS_IAM` requires SigV4-signed proxy requests. +::: + +## 6. The `/rpc` proxy route + +Forward POST requests to the backend: + +```typescript +// src/routes/rpc.ts +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/rpc")({ + server: { + handlers: { + POST: async ({ request }) => + fetch(process.env.BACKEND_URL!, { + method: "POST", + headers: { "content-type": "application/json" }, + body: await request.text(), + }), + }, + }, +}); +``` + +## 7. The atom RPC client + +Create the browser client with Effect 4’s `AtomRpc`: + +```typescript +// src/rpc-client.ts +import * as Layer from "effect/Layer"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as AtomRpc from "effect/reactivity/AtomRpc"; +import { RpcClient, RpcSerialization } from "effect/rpc"; +import { TodoRpcs } from "./backend/rpc.ts"; + +export class TodoClient extends AtomRpc.Service()("TodoClient", { + group: TodoRpcs, + protocol: RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(RpcSerialization.layerJson), + ), +}) {} + +// Created once at module scope so their identity is stable across renders. +export const listTodosAtom = TodoClient.query("listTodos", undefined, { + reactivityKeys: ["todos"], +}); +export const createTodoAtom = TodoClient.mutation("createTodo"); +export const toggleTodoAtom = TodoClient.mutation("toggleTodo"); +export const deleteTodoAtom = TodoClient.mutation("deleteTodo"); +``` + +Mutations with `reactivityKeys: ["todos"]` invalidate and refetch the list. + +## 8. Provide a registry + +Wrap the application in `RegistryProvider`: + +```tsx +// src/routes/__root.tsx +import { RegistryProvider } from "@effect/atom-react"; +import { Outlet, createRootRoute } from "@tanstack/react-router"; + +export const Route = createRootRoute({ component: RootComponent }); + +function RootComponent() { + return ( + // One AtomRegistry for the whole app — every query/mutation atom + // resolves against it. + + + + ); +} +``` + +## 9. The UI + +Read the query with `useAtomValue`; call mutations with `useAtomSet`: + +```tsx +// src/routes/index.tsx +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/reactivity/AsyncResult"; +import { useState } from "react"; +import { + createTodoAtom, + deleteTodoAtom, + listTodosAtom, + toggleTodoAtom, +} from "../rpc-client.ts"; + +function TodoForm() { + const createTodo = useAtomSet(createTodoAtom); + const [text, setText] = useState(""); + + return ( +
{ + e.preventDefault(); + const value = text.trim(); + if (!value) return; + createTodo({ payload: { text: value }, reactivityKeys: ["todos"] }); + setText(""); + }} + > + setText(e.target.value)} /> + +
+ ); +} + +function TodoList() { + const atom = useAtomValue(listTodosAtom); + const toggleTodo = useAtomSet(toggleTodoAtom); + const deleteTodo = useAtomSet(deleteTodoAtom); + + const todos = AsyncResult.getOrElse(atom, () => []); + + if (AsyncResult.isWaiting(atom) && !todos.length) { + return

Loading todos…

; + } + + return ( +
    + {todos.map((todo) => ( +
  • + + toggleTodo({ + payload: { id: todo.id, done: !todo.done }, + reactivityKeys: ["todos"], + }) + } + /> + {todo.text} + +
  • + ))} +
+ ); +} +``` + +## 10. Deploy + +[AWS setup](/aws/setup) + +```sh +bun alchemy deploy +``` + +Open `websiteUrl` to create, toggle, and delete todos. + +## Local dev + +```sh +bun alchemy dev +``` + +The frontend runs locally; the backend Lambda and DSQL remain on AWS and incur charges. + +## Recap + +`TodoRpcs` defines the API; the frontend proxies `/rpc`; mutation keys refresh the list. + +## Where to go next + +[Effect RPC](/aws/apis/effect-rpc) · [TanStack Start](/aws/frontend/tanstack-start) · [Drizzle + DSQL](/aws/data/drizzle-dsql) · [Drizzle queries](/sql/drizzle/postgres) · [RDS & Aurora](/aws/data/rds) diff --git a/.agents/skills/alchemy/references/aws/frontend/nextjs.md b/.agents/skills/alchemy/references/aws/frontend/nextjs.md new file mode 100644 index 00000000..127457d8 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/frontend/nextjs.md @@ -0,0 +1,167 @@ + + +# Next.js + +> Deploy a Next.js app to AWS with AWS.Website.Nextjs — the OpenNext serverless topology with streaming SSR, image optimization, and ISR wiring, plus next dev under alchemy dev. + +`AWS.Website.Nextjs` deploys a [Next.js](https://nextjs.org) app to +AWS using the [OpenNext](https://opennext.js.org/aws) serverless +topology: + +- the Next server on a Lambda Function URL with response streaming +- static assets and the prerender cache in S3, served through + CloudFront's edge router +- a dedicated image-optimization Lambda behind `/_next/image` +- ISR wiring: an SQS revalidation queue with a consumer Lambda and a + DynamoDB tag-cache table + +App Router and Pages Router both work — server components, API +routes, middleware, server actions, dynamic segments, streaming SSR, +and `getServerSideProps` pages all run in the Lambda. + +## Install + +The build integration is not bundled with alchemy. Install +`@alchemy.run/frontend-frameworks` and its OpenNext peer; the +resource loads the package's `/nextjs/aws` export from your project +at deploy time. They are only used at build time, so dev dependencies +are enough: + +```sh +bun add -d @alchemy.run/frontend-frameworks @opennextjs/aws +``` + +## Configure OpenNext + +Your `next.config.*` is loaded and honored as-is — the build runs a +real `next build`, and Alchemy never rewrites the file. An +`open-next.config.ts` is optional: when the project has none, a +minimal default with the streaming server wrapper is generated for +you. Keep your own file next to `next.config.*` to customize the +OpenNext build: + +```typescript +// open-next.config.ts +export default { + default: { + override: { + wrapper: "aws-lambda-streaming", + }, + }, +}; +``` + +## Declare the Website + +Declare the site as a module-level const (rather than inline in the +Stack): + +```typescript +// alchemy.run.ts +import * as AWS from "alchemy/AWS"; + +export const Website = AWS.Website.Nextjs("Website"); +``` + +## Add it to the Stack + +Yield the site from your Stack and return its URL: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "MyNextjsSite", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { + const site = yield* Website; + return { url: site.url }; + }), +); +``` + +Statically-rendered pages are served from the S3 prerender cache; +dynamic pages and route handlers stream from the server Lambda; +`next/image` requests are handled by the dedicated optimizer. + +See +[examples/aws-website-nextjs](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-website-nextjs) +for the checked-in example. + +## Add environment variables + +The server function's environment is configured under `env` — plain +values, or outputs from other resources in the Stack: + +```diff lang="typescript" +// alchemy.run.ts +export const Website = AWS.Website.Nextjs("Website", { ++ env: { ++ GREETING: "Hello from Alchemy!", ++ API_BASE: api.url, ++ }, +}); +``` + +The values are set on the server Lambda at deploy time and injected +into the dev server's process environment under `alchemy dev`, so +server code reads the same values in both modes. + +## Read the environment in server code + +The OpenNext server runs in a plain Node Lambda, so route handlers, +server components, and server actions read the environment from +`process.env`: + +```typescript +// app/api/hello/route.ts +export function GET() { + return Response.json({ greeting: process.env.GREETING ?? "hello" }); +} +``` + +## ISR + +Incremental Static Regeneration works out of the box — the resource +provisions the OpenNext cache infrastructure for you: + +- the ISR/fetch cache lives in a dedicated S3 bucket +- `revalidatePath` / `revalidateTag` purge entries through the + DynamoDB tag-cache table +- time-based `revalidate` windows regenerate pages in the background + through the SQS FIFO revalidation queue and its consumer Lambda + +There is no cache configuration to write — the resource wires +OpenNext's standard cache environment (`CACHE_BUCKET_NAME`, +`CACHE_DYNAMO_TABLE`, `REVALIDATION_QUEUE_URL`) onto the server +Lambda automatically, so OpenNext's default S3 / DynamoDB / SQS +overrides just work. + +## Local dev + +```sh +bun alchemy dev +``` + +`alchemy dev` runs `next dev` (native HMR, Turbopack) instead of +deploying; `site.url` is the local address and no AWS resources are +created. Wrap the site in `Alchemy.remote()` to deploy the real +infrastructure even during dev. + +## Custom domain + +```typescript +const site = yield* AWS.Website.Nextjs("Web", { + domain: { name: "app.example.com" }, +}); +``` + +The hosted zone is inferred from the hostname. Pass `hostedZoneId` to +pin it when several zones could match. diff --git a/.agents/skills/alchemy/references/aws/frontend/nuxt.md b/.agents/skills/alchemy/references/aws/frontend/nuxt.md new file mode 100644 index 00000000..37ef900e --- /dev/null +++ b/.agents/skills/alchemy/references/aws/frontend/nuxt.md @@ -0,0 +1,203 @@ + + +# Nuxt + +> Deploy a Nuxt app to AWS with AWS.Website.Nuxt — nitro's aws-lambda preset on a streaming Lambda Function URL, S3 assets behind CloudFront, and Nuxt's own dev server under alchemy dev. + +`AWS.Website.Nuxt` deploys a [Nuxt](https://nuxt.com) app to AWS. It +builds the app through your project's own `@nuxt/kit` with nitro's +`aws-lambda` preset: the nitro server runs on a Lambda Function URL +with response streaming, and client assets plus prerendered pages are +served from a private S3 bucket through CloudFront. A CloudFront +Function routes each request at the edge — uploaded files go to S3, +everything else streams from the Lambda. There is no `nitro.preset` +to edit and no build command to run. + +## Install + +The build integration is not bundled with alchemy. Install +`@alchemy.run/frontend-frameworks`; the resource loads its `/nuxt` and +`/nuxt/aws` exports from your project at deploy time. It is only used +at build time, so a dev dependency is enough: + +```sh +bun add -d @alchemy.run/frontend-frameworks +``` + +## Configure Nuxt + +Your `nuxt.config.ts` loads natively — modules, layers, and all — +so configure Nuxt exactly as you would outside Alchemy: + +```typescript +// nuxt.config.ts +export default defineNuxtConfig({ + modules: ["@nuxtjs/tailwindcss"], + routeRules: { + "/about": { prerender: true }, + }, +}); +``` + +Don't set `nitro.preset` — the AWS deploy target owns the preset +(`aws-lambda` with streaming enabled), and a foreign preset is a hard +error. + +## Deploy-time overrides + +Config that only exists at deploy time — a stage-dependent base URL, a +per-environment `runtimeConfig` — goes in the `nuxt` prop. It merges +over `nuxt.config.ts` as the highest-priority layer, so a value here +wins: + +```typescript +export const Website = AWS.Website.Nuxt("Website", { + nuxt: { + app: { baseURL: "/docs/" }, + runtimeConfig: { + public: { apiBase: "https://api.example.com" }, + }, + }, +}); +``` + +The bag must be JSON-serializable — no functions, plugins, or modules; +those belong in `nuxt.config.ts`. `nitro.preset` stays owned by the +deploy target. + +## Declare the Website + +Declare the site as a module-level const (rather than inline in the +Stack): + +```typescript +// alchemy.run.ts +import * as AWS from "alchemy/AWS"; + +export const Website = AWS.Website.Nuxt("Website"); +``` + +## Add it to the Stack + +Yield the site from your Stack and return its URL: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "MyNuxtSite", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { + const site = yield* Website; + return { url: site.url }; + }), +); +``` + +See +[examples/aws-website-nuxt](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-website-nuxt) +for the checked-in example. + +## Add environment variables + +The server function's environment is configured under `env` — plain +values, or outputs from other resources in the Stack: + +```diff lang="typescript" +// alchemy.run.ts +export const Website = AWS.Website.Nuxt("Website", { ++ memorySize: 2048, ++ env: { ++ NUXT_PUBLIC_API_BASE: api.url, ++ }, +}); +``` + +`env` values are set on the Lambda on deploy and injected into +the dev server's process environment under `alchemy dev`, so server +code reads the same values in both modes. Sibling props tune the +Lambda itself (`memorySize`, `timeout`, `architecture`, `runtime`). + +## Read the environment in server code + +On AWS the nitro server runs in a plain Node Lambda, so server routes +and SSR read the environment from `process.env` — or, the more +idiomatic Nuxt way, through `runtimeConfig` overridden by +`NUXT_`-prefixed environment variables: + +```typescript +// server/api/hello.ts +export default defineEventHandler(() => { + return { greeting: process.env.GREETING ?? "hello" }; +}); +``` + +## Prerendering + +Routes marked for prerendering in `routeRules` (or via +`nitro.prerender`) render at build time into `.output/public` and are +uploaded to S3, served from the edge with no Lambda invocation. +Configure them in your project's `nuxt.config.ts`: + +```typescript +// nuxt.config.ts +export default defineNuxtConfig({ + routeRules: { + "/about": { prerender: true }, + }, +}); +``` + +Nitro's `isr` route rule is implemented only by the Vercel and +Netlify presets. On AWS Lambda it is silently ignored at build time, +and the route renders on demand in the Lambda like any other SSR +route. Use `prerender` for build-time static routes, or `cache` route +rules for runtime caching. + +## Local dev + +```sh +bun alchemy dev +``` + +`alchemy dev` runs Nuxt's own dev server (nitro dev, full HMR) +instead of deploying anything; `site.url` is the dev server's +`http://localhost:` address and no AWS resources are created. +Wrap the site in `Alchemy.remote()` to deploy the real infrastructure +even during dev. + +## Custom domain + +```typescript +const site = yield* AWS.Website.Nuxt("Web", { + domain: { name: "app.example.com" }, +}); +``` + +The certificate is created in `us-east-1` (required by CloudFront), +DNS validation records and the alias record are managed through +Route 53. The hosted zone is inferred from the hostname — pass +`hostedZoneId` to pin it when several zones could match. + +## Share a distribution with Router + +CloudFront distributions take minutes to create. To serve several +sites (or a site plus an API) from one distribution, attach the site +to an [`AWS.Website.Router`](/aws/frontend/static-site#compose-sites-with-a-router): + +```typescript +const router = yield* AWS.Website.Router("FrontDoor", { + domain: { name: "example.com" }, +}); + +const site = yield* AWS.Website.Nuxt("Web", { + domain: { router }, +}); +``` diff --git a/.agents/skills/alchemy/references/aws/frontend/octane.md b/.agents/skills/alchemy/references/aws/frontend/octane.md new file mode 100644 index 00000000..d88d4893 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/frontend/octane.md @@ -0,0 +1,146 @@ + + +# Octane + +> Deploy an OctaneJS app to AWS with AWS.Website.Octane — SSR on a streaming Lambda Function URL, assets on S3 + CloudFront, and Octane's own Vite dev server under alchemy dev. + +`AWS.Website.Octane` deploys an [OctaneJS](https://octanejs.dev) +fullstack app to AWS. Octane wraps Vite, so the resource is +deliberately thin: it runs your project's own `vite build` — Octane's +plugin builds the client bundle and the self-contained SSR server +bundle, and the AWS deploy target's finishing pass wraps its fetch +handler as a streaming Lambda handler. The server deploys on a Lambda +Function URL; `dist/client` is served from a private S3 bucket +through CloudFront. No CloudFormation, no build command to run. + +## Install + +The build integration is not bundled with alchemy. Install +`@alchemy.run/frontend-frameworks`; the resource loads the package's +`/octane` and `/octane/aws` exports from your project at deploy time. +It is only used at build time, so a dev dependency is enough: + +```sh +bun add -d @alchemy.run/frontend-frameworks +``` + +## Configure Octane + +Keep native compiler and route settings in `octane.config.ts`, without +an adapter: + +```typescript +// octane.config.ts +import { defineConfig, RenderRoute } from "@octanejs/vite-plugin"; + +export default defineConfig({ + router: { + routes: [new RenderRoute({ path: "/", entry: ["App", "/src/App.tsx"] })], + }, +}); +``` + +`AWS.Website.Octane` selects hosting and automatically wraps Octane's +default native Node output as a streaming Lambda handler. The legacy AWS +marker adapter remains optional for existing projects. + +## Declare the Website + +Declare the site as a module-level const (rather than inline in the +Stack): + +```typescript +// alchemy.run.ts +import * as AWS from "alchemy/AWS"; + +export const Website = AWS.Website.Octane("Website"); +``` + +## Add it to the Stack + +Yield the site from your Stack and return its URL: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "MyOctaneSite", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { + const site = yield* Website; + return { url: site.url }; + }), +); +``` + +## Add environment variables + +The server function's environment is configured under `env` — plain +values, or outputs from other resources in the Stack: + +```diff lang="typescript" +// alchemy.run.ts +export const Website = AWS.Website.Octane("Website", { ++ env: { ++ GREETING: "Hello from alchemy", ++ API_BASE: api.url, ++ }, +}); +``` + +The values are set on the Lambda at deploy time and injected into the +dev server's process environment under `alchemy dev`, so server code +reads the same values in both modes. + +## Read the environment in server code + +Octane's server runs in a plain Node Lambda, so middleware and +`ServerRoute` handlers read the environment from `process.env`: + +```typescript +// octane.config.ts +new ServerRoute({ + path: "/api/greeting", + methods: ["GET"], + handler: async () => { + return Response.json({ greeting: process.env.GREETING ?? "hello" }); + }, +}); +``` + +## Asset routing + +Octane's intended setup is asset-first with SSR on miss: exact files +in `dist/client` serve from S3 via the CloudFront edge router without +invoking the server, and every miss streams from Octane SSR on the +Lambda. That is the default — there is nothing to configure. + +## Local dev + +```sh +bun alchemy dev +``` + +`alchemy dev` runs Octane's own Vite dev server — the plugin's +in-process SSR middleware serves rendering, server routes, and RPC +with full HMR. `site.url` is the local address and no AWS resources +are created. Wrap the site in `Alchemy.remote()` to deploy the real +infrastructure even during dev. + +## Custom domain + +```typescript +const site = yield* AWS.Website.Octane("Web", { + domain: { name: "app.example.com" }, +}); +``` + +The hosted zone is inferred from the hostname. Pass `hostedZoneId` to +pin it when several zones could match. diff --git a/.agents/skills/alchemy/references/aws/frontend/react-router.md b/.agents/skills/alchemy/references/aws/frontend/react-router.md new file mode 100644 index 00000000..e4493429 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/frontend/react-router.md @@ -0,0 +1,306 @@ + + +# React Router + +> Deploy React Router v7 (framework mode) to AWS with AWS.Website.ReactRouter — SSR on a streaming Lambda Function URL, client assets on S3 + CloudFront, and React Router's own Vite dev server under alchemy dev. + +`AWS.Website.ReactRouter` deploys a +[React Router](https://reactrouter.com) v7 app in framework mode to AWS. +React Router builds through Vite, so Alchemy runs your project's own +build: the SSR server runs on a Lambda Function URL with response +streaming, and client assets are served from a private S3 bucket through +CloudFront. A CloudFront Function routes each request at the edge — +uploaded files go to S3, everything else streams from the Lambda. + +## Install + +The build integration is not bundled with alchemy. Install +`@alchemy.run/frontend-frameworks`; the resource loads its +`/react-router` and `/react-router/aws` exports from your project at +deploy time. It is only used at build time, so a dev dependency is +enough: + +```sh +bun add -d @alchemy.run/frontend-frameworks +``` + +The React Router side is whatever `npx create-react-router@latest` +scaffolds — `react-router`, `@react-router/dev`, `@react-router/node`, +`isbot`, and `vite`. Keep `@react-router/node` and `isbot` in +`dependencies`: React Router picks its default `entry.server` by reading +them from your `package.json`, and installs `isbot` on the fly if it is +missing. + +## Configure Vite + +Your `vite.config.ts` stays exactly what the React Router scaffold gives +you — no adapter, no server preset: + +```typescript +// vite.config.ts +import { reactRouter } from "@react-router/dev/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [reactRouter()], +}); +``` + +`reactRouter()` owns routing, typegen, and both build environments. +Alchemy drives the same two passes `react-router build` runs, forces the +SSR bundle to be self-contained, and wraps its fetch handler as a +streaming Lambda handler. + +:::caution[Remove @cloudflare/vite-plugin if present] +The React Router CLI adds `@cloudflare/vite-plugin` when you pick the +Cloudflare template. It builds for workerd, not Node, so an AWS deploy +needs it gone: + +```diff lang="typescript" +// vite.config.ts +-import { cloudflare } from "@cloudflare/vite-plugin"; +import { reactRouter } from "@react-router/dev/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ +- plugins: [cloudflare({ viteEnvironment: { name: "ssr" } }), reactRouter()], ++ plugins: [reactRouter()], +}); +``` + +::: + +## Declare the Website + +Declare the site as a module-level const (rather than inline in the +Stack): + +```typescript +// alchemy.run.ts +import * as AWS from "alchemy/AWS"; + +export const Website = AWS.Website.ReactRouter("Website"); +``` + +`rootDir` is the project root — the directory holding `package.json`, +`vite.config.*`, and `react-router.config.ts`. It defaults to `"."`, so +set it only when the app does not sit next to `alchemy.run.ts`: + +```typescript +export const Website = AWS.Website.ReactRouter("Website", { + rootDir: "./web", +}); +``` + +## Add it to the Stack + +Yield the site from your Stack and return its URL: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "MyReactRouterSite", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { + const site = yield* Website; + return { url: site.url }; + }), +); +``` + +Requests matching a built client asset are served straight from S3; +everything else — SSR routes, loaders, actions, resource routes — falls +through to the Lambda. + +## How the two build outputs are deployed + +`react-router build` writes two directories, and each one becomes a +different piece of AWS infrastructure: + +``` +build/ +├── client/ -> uploaded to S3, served through CloudFront +└── server/ -> the Lambda deployment package +``` + +`build/client` holds the hashed browser bundles plus everything you put +in `public/`. `build/server/index.js` is a `ServerBuild` manifest, not a +request handler, so Alchemy wraps it with `createRequestHandler` during +the build and emits `build/server/lambda.mjs` beside it — that module is +the deployed Lambda entry. + +If you set Vite's `base`, set React Router's `basename` to the same +prefix: the client directory maps onto the site root one-for-one, so the +paths the SSR document emits have to be the paths S3 was given. + +## Add environment variables + +The server function's environment is configured under `env`: + +```diff lang="typescript" +// alchemy.run.ts +export const Website = AWS.Website.ReactRouter("Website", { ++ memorySize: 2048, ++ env: { ++ API_BASE: "https://api.example.com", ++ }, +}); +``` + +`env` values are set on the Lambda on deploy and injected into +the dev server's process environment under `alchemy dev`, so server code +reads the same values in both modes. Sibling props tune the +Lambda itself (`memorySize`, `timeout`, `architecture`, `runtime`). + +An output from another resource only exists once that resource is +yielded, so declare the site inside the Stack generator instead: + +```typescript +// alchemy.run.ts +Effect.gen(function* () { + const api = yield* Api; + + const site = yield* AWS.Website.ReactRouter("Website", { + env: { API_BASE: api.url }, + }); + + return { url: site.url }; +}); +``` + +## Read the environment in a loader + +The React Router server runs in a plain Node Lambda, so loaders and +actions read the environment from `process.env`: + +```typescript +// app/routes/home.tsx +export function loader() { + return { apiBase: process.env.API_BASE ?? "unset" }; +} +``` + +Values prefixed `VITE_` are inlined into the client bundle at build time +instead, as `import.meta.env.VITE_*` — set those in the shell that runs +`alchemy deploy`, not in `env`. + +## Read the environment in a resource route + +A route module with no default export is a resource route, served by the +same Lambda: + +```typescript +// app/routes/api.hello.ts +export function loader() { + return new Response(process.env.API_BASE ?? "unset"); +} +``` + +## Call AWS services from server code + +The composite creates and owns the server Lambda's execution role, and +exposes no prop to add permissions to it. Server code that needs to +reach AWS goes behind its own Lambda, where capabilities are bound +explicitly: + +```typescript +// alchemy.run.ts +Effect.gen(function* () { + const api = yield* Api; + + const site = yield* AWS.Website.ReactRouter("Website", { + env: { API_BASE: api.url }, + }); + + return { url: site.url }; +}); +``` + +A loader then calls that API over HTTP with the URL it was handed: + +```typescript +// app/routes/home.tsx +export async function loader() { + const res = await fetch(`${process.env.API_BASE}/hello`); + return { body: await res.text() }; +} +``` + +See [Effect HTTP API](/aws/apis/effect-http-api) for building that +Lambda and binding it to the resources it reads. + +## Build configuration + +The build runs through your project's own `vite.config.ts` and +`react-router.config.ts`. A custom `buildDirectory` just works — the +integration observes the directory from the resolved Vite config, so +there is nothing to mirror on the resource. + +## Local dev + +```sh +bun alchemy dev +``` + +`alchemy dev` runs React Router's own Vite dev server (native HMR) +instead of deploying anything; `site.url` is the dev server's +`http://localhost:` address and no AWS resources are created. Wrap +the site in `Alchemy.remote()` to deploy the real infrastructure even +during dev. + +## React Server Components + +RSC is not supported on AWS yet. React Router's `unstable` RSC plugin — +and the `future.v8_viteEnvironmentApi` flag — emit more than one server +environment, and this resource assembles a single one. A build that +produces no single server entry fails with an actionable error rather +than deploying something broken. For RSC today, deploy to Cloudflare +with [`Cloudflare.Website.Vite`](/cloudflare/frontend/react-router), +which takes a `viteEnvironments` description of the environment +topology. + +## Custom domain + +```typescript +// alchemy.run.ts +export const Website = AWS.Website.ReactRouter("Website", { + domain: { name: "app.example.com" }, +}); +``` + +The certificate is created in `us-east-1` (required by CloudFront), DNS +validation records and the alias record are managed through Route 53. +The hosted zone is inferred from the hostname — pass `hostedZoneId` to +pin it when several zones could match. + +## Share a distribution with Router + +CloudFront distributions take minutes to create. To serve several sites +(or a site plus an API) from one distribution, attach the site to an +[`AWS.Website.Router`](/aws/frontend/static-site#compose-sites-with-a-router): + +The site takes the Router's outputs, so both are yielded inside the +Stack generator: + +```typescript +// alchemy.run.ts +Effect.gen(function* () { + const router = yield* AWS.Website.Router("FrontDoor", { + domain: { name: "example.com" }, + }); + + const site = yield* AWS.Website.ReactRouter("Website", { + domain: { router }, + }); + + return { url: site.url }; +}); +``` diff --git a/.agents/skills/alchemy/references/aws/frontend/solidstart.md b/.agents/skills/alchemy/references/aws/frontend/solidstart.md new file mode 100644 index 00000000..021e5335 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/frontend/solidstart.md @@ -0,0 +1,239 @@ + + +# SolidStart + +> Deploy a SolidStart app to AWS with AWS.Website.SolidStart — nitro's aws-lambda preset on a streaming Lambda Function URL, S3 assets behind CloudFront, and SolidStart's own Vite dev server under alchemy dev. + +`AWS.Website.SolidStart` deploys a +[SolidStart](https://start.solidjs.com) app to AWS. It runs your +project's own `vite build` and appends nitro's `aws-lambda` preset: +the SSR server runs on a Lambda Function URL with response streaming, +and client assets plus prerendered pages are served from a private S3 +bucket through CloudFront. A CloudFront Function routes each request +at the edge — uploaded files go to S3, everything else streams from +the Lambda. + +:::note[Verified version line] +This page tracks `@solidjs/start` **2.x** with +`@solidjs/vite-plugin-nitro-2` — the Vite-native line where +`solidStart()` is a plain Vite plugin and nitro is a second plugin. +::: + +## Install + +The build integration is not bundled with alchemy. Install +`@alchemy.run/frontend-frameworks`; the resource loads its +`/solidstart` and `/solidstart/aws` exports from your project at +deploy time. It is only used at build time, so a dev dependency is +enough: + +```sh +bun add -d @alchemy.run/frontend-frameworks +``` + +`@solidjs/vite-plugin-nitro-2` is the server half of a SolidStart +build and must be a project dependency too: + +```sh +bun add @solidjs/vite-plugin-nitro-2 +``` + +## Configure Vite + +Your `vite.config.ts` is just the SolidStart plugin — no adapter, no +nitro wiring: + +```typescript +// vite.config.ts +import { solidStart } from "@solidjs/start/config"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [solidStart()], +}); +``` + +Alchemy appends its own `nitroV2Plugin()` instance at build time, +carrying the `aws-lambda` preset with response streaming enabled. +Don't register `nitroV2Plugin()` yourself — the appended instance +would discard its options, so the build fails with an actionable +error instead. Pass nitro options through the resource's `nitro` +prop; see [Prerendering](#prerendering) below. + +## Declare the Website + +Declare the site as a module-level const (rather than inline in the +Stack): + +```typescript +// alchemy.run.ts +import * as AWS from "alchemy/AWS"; + +export const Website = AWS.Website.SolidStart("Website"); +``` + +`rootDir` is the project root — the directory holding `package.json` +and `vite.config.*`. It defaults to `"."`, so set it only when the app +does not sit next to `alchemy.run.ts`: + +```typescript +export const Website = AWS.Website.SolidStart("Website", { + rootDir: "./app", +}); +``` + +## Add it to the Stack + +Yield the site from your Stack and return its URL: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "MySolidStartSite", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { + const site = yield* Website; + return { url: site.url }; + }), +); +``` + +## Add environment variables + +The server function's environment is configured under `env`: + +```diff lang="typescript" +// alchemy.run.ts +export const Website = AWS.Website.SolidStart("Website", { ++ memorySize: 2048, ++ env: { ++ API_BASE: "https://api.example.com", ++ }, +}); +``` + +`env` values are set on the Lambda on deploy and injected into +the dev server's process environment under `alchemy dev`, so server +code reads the same values in both modes. Sibling props tune the +Lambda itself (`memorySize`, `timeout`, `architecture`, `runtime`). + +An output from another resource only exists once that resource is +yielded, so declare the site inside the Stack generator instead: + +```typescript +// alchemy.run.ts +Effect.gen(function* () { + const api = yield* Api; + + const site = yield* AWS.Website.SolidStart("Website", { + env: { API_BASE: api.url }, + }); + + return { url: site.url }; +}); +``` + +## Read the environment in server code + +The SolidStart server runs in a plain Node Lambda, so API routes, +server functions, and SSR read the environment from `process.env`: + +```typescript +// src/routes/api/hello.ts +export function GET() { + return new Response(process.env.API_BASE ?? "unset"); +} +``` + +Values prefixed `VITE_` are inlined into the client bundle at build +time instead, as `import.meta.env.VITE_*` — set those in the shell +that runs `alchemy deploy`, not in `env`. + +## Prerendering + +Nitro renders the routes you list at build time into `.output/public`. +They are uploaded to S3 and served from the edge with no Lambda +invocation: + +```typescript +export const Website = AWS.Website.SolidStart("Website", { + nitro: { prerender: { routes: ["/", "/about"] } }, +}); +``` + +`prerender` takes the routes to render (`routes`) and can crawl links +from prerendered pages to prerender them too (`crawlLinks`). The +`nitro` prop is the home for all nitro options (route rules, storage, +...) — the integration owns the plugin instance, so they cannot live +in `vite.config.ts`. Values must be JSON-serializable; `preset` is +owned by the AWS deploy target, and a foreign preset is a hard error. + +## Local dev + +```sh +bun alchemy dev +``` + +`alchemy dev` runs SolidStart's own Vite dev server (native HMR) +instead of deploying anything; `site.url` is the dev server's +`http://localhost:` address and no AWS resources are created. +Nitro plays no part in dev. Wrap the site in `Alchemy.remote()` to +deploy the real infrastructure even during dev. + +SolidStart's plugin resolves its app root and route directory from +the process working directory, so one `alchemy dev` process serves +one SolidStart app at a time. + +:::caution[Run `alchemy dev` under Node] +SolidStart 2's dev server answers requests through `srvx/node`, which +returns the string `[object Object]` instead of the rendered document +when it runs under Bun. Production builds are unaffected — only the +dev server is. Until that upstream gap closes, run `alchemy dev` with +Node (`npx alchemy dev`) for SolidStart projects. +::: + +## Custom domain + +```typescript +// alchemy.run.ts +export const Website = AWS.Website.SolidStart("Website", { + domain: { name: "app.example.com" }, +}); +``` + +The certificate is created in `us-east-1` (required by CloudFront), +DNS validation records and the alias record are managed through +Route 53. The hosted zone is inferred from the hostname — pass +`hostedZoneId` to pin it when several zones could match. + +## Share a distribution with Router + +CloudFront distributions take minutes to create. To serve several +sites (or a site plus an API) from one distribution, attach the site +to an [`AWS.Website.Router`](/aws/frontend/static-site#compose-sites-with-a-router): + +The site takes the Router's outputs, so both are yielded inside the +Stack generator: + +```typescript +// alchemy.run.ts +Effect.gen(function* () { + const router = yield* AWS.Website.Router("FrontDoor", { + domain: { name: "example.com" }, + }); + + const site = yield* AWS.Website.SolidStart("Website", { + domain: { router }, + }); + + return { url: site.url }; +}); +``` diff --git a/.agents/skills/alchemy/references/aws/frontend/static-site.md b/.agents/skills/alchemy/references/aws/frontend/static-site.md new file mode 100644 index 00000000..3c70f607 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/frontend/static-site.md @@ -0,0 +1,225 @@ + + +# Static sites + +> Ship a static site to S3 + CloudFront with AWS.Website.StaticSite — build-step support, Router composition, and cache invalidation on deploy. + +`AWS.Website.StaticSite` deploys a directory of files as a website: it uploads +the files to a private S3 bucket, creates a CloudFront distribution with a +CloudFront Function that routes requests at the edge via a KeyValueStore file +manifest, and invalidates the cache when your assets change. Use it whenever +the output is plain files — docs, marketing pages, or a built SPA. + +## Deploy a directory + +Point `path` at a directory of files and return the site's `url` as a stack +output: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "MyStaticSite", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { + const site = yield* AWS.Website.StaticSite("MarketingSite", { + path: "./site", + forceDestroy: true, + }); + + return { + url: site.url, + }; + }), +); +``` + +One `StaticSite` expands into the whole stack — an S3 bucket, the asset +upload, a CloudFront KeyValueStore holding the file manifest, a viewer-request +CloudFront Function, and the distribution itself. `forceDestroy: true` lets +`destroy` empty the bucket before deleting it; `site.url` resolves to the +CloudFront domain (or `https://` when you pass `domain`). + +```sh +bun alchemy deploy +``` + +The deployed example lives at +[`examples/aws-static-site`](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-static-site) +if you want a runnable starting point. + +## Run a build step first + +For framework sites (Vite, Astro, etc.), add `build` so the framework's build +runs before upload and the `output` directory is what gets deployed: + +```typescript +const site = yield* AWS.Website.StaticSite("Web", { + path: "./frontend", + build: { + command: "bun run build", + output: "dist", + env: { + VITE_API_URL: api.url, + }, + }, +}); +``` + +The build step is a [Command.Build](/providers/command/build) resource — +memoized, so unchanged inputs skip the build +([details](/command/memoization)). The input hash covers, by default, all +files under `path` (filtered by your gitignore rules) plus the nearest +package-manager lockfile. Tune the hash with `build.include`, `build.exclude`, and +`build.lockfile` — e.g. `include: ["src/**", "package.json"]` to re-run only +when source files change. `build.env` variables are passed to the build +command, so outputs from other resources (like an API URL) flow straight into +the frontend bundle. + +## SPAs and 404 pages + +A miss — a request that matches no uploaded file — can be answered two ways, +and they are mutually exclusive: + +```typescript +// Single-page app: misses serve index.html with a 200 so the +// client-side router takes over. +const app = yield* AWS.Website.StaticSite("App", { + path: "./app", + build: { command: "bun run build", output: "dist" }, + spa: true, +}); + +// Static site: misses return a real 404 status with your error page. +const docs = yield* AWS.Website.StaticSite("Docs", { + path: "./docs/dist", + errorPage: "404.html", +}); +``` + +Pretty URLs work in both modes: `/about` serves `about.html` or +`about/index.html` when one was uploaded, resolved at the edge from the file +manifest. + +## Compose sites with a Router + +`AWS.Website.Router` is a shared CloudFront front door: one distribution whose +routes live in a KeyValueStore, matched at the edge by a CloudFront Function. +Create the router, then attach sites to it instead of giving each site its own +distribution: + +```typescript +const router = yield* AWS.Website.Router("WebsiteRouter", { + // hosted zone inferred from the hostname; pass hostedZoneId to pin it + domain: { name: "example.com" }, +}); + +const site = yield* AWS.Website.StaticSite("Docs", { + path: "./docs", + domain: { + router, + path: "/docs", + }, +}); +``` + +The site registers itself by writing its file manifest and metadata into the +router's KV store — no distribution redeploy is needed to add or remove a +site. In router mode, `domain.name` is an optional host pattern (exact +hostname or `*.example.com`) matched at the edge; the hostname must also be +covered by the Router's own `domain`. `edge` and `cloudfrontUrl` live on the +`Router` — setting them on an attached site is an error. + +## Route to non-site origins + +The router also takes inline `routes` for origins that aren't managed by +`StaticSite`, keyed by path pattern: + +```typescript +const router = yield* AWS.Website.Router("WebsiteRouter", { + routes: { + "/*": { url: api.functionUrl }, + }, +}); +``` + +A route value can be a URL (proxied as a custom origin) or a +`{ bucket }` route serving straight from S3, optionally with a +`rewrite: { regex, to }` applied to the URI before forwarding. Longest +host-then-path match wins, so a `StaticSite` mounted at `/docs` takes +precedence over a `/*` fallback route. + +## Cache invalidation on deploy + +By default every deploy that changes your files creates a CloudFront +invalidation for `/*` and moves on without waiting +(`{ paths: "all", wait: false }`). The invalidation is keyed to a content hash +of the uploaded assets, so deploys that change nothing don't invalidate +anything. Tune it with `invalidation`: + +```typescript +const site = yield* AWS.Website.StaticSite("MarketingSite", { + path: "./site", + invalidation: { + paths: "all", + wait: true, + }, +}); +``` + +- `paths: "all"` — invalidate `/*` (the default) +- `paths: "versioned"` — invalidate only the index page, for sites whose + assets are content-hashed and immutable +- `paths: [...]` — an explicit list of paths +- `wait: true` — block the deploy until CloudFront reports the invalidation + complete +- `invalidation: false` — skip invalidation entirely + +## Local dev + +During `alchemy dev`, the `dev` prop replaces the build (and the whole +S3 + CloudFront deployment) with your framework's own dev server: + +```typescript +const site = yield* AWS.Website.StaticSite("Web", { + path: "./frontend", + build: { command: "bun run build", output: "dist" }, + dev: { command: "bun run dev" }, +}); +``` + +`dev.command` is spawned as a long-lived sidecar process tied to the +stack's scope — the build is skipped, no AWS resources are created, and +`site.url` is the dev server's local address, detected from the +command's stdout. `dev` also accepts `cwd` and `env` overrides for the +dev process, and `dev.url` to pin the URL explicitly when stdout +detection fails. Wrap the site in `Alchemy.remote()` to deploy the real +infrastructure even during dev. + +## When to use a framework resource instead + +`StaticSite` is the general fallback for any directory of files (with or +without a build command). Frameworks with dedicated resources have a +better path: [Astro](/aws/frontend/astro), +[Next.js](/aws/frontend/nextjs), [Nuxt](/aws/frontend/nuxt), +[SvelteKit](/aws/frontend/sveltekit), and [Waku](/aws/frontend/waku) +run the framework's own programmatic build, deploy server-rendered +routes on a streaming Lambda, and skip the build contract entirely. +Reach for `StaticSite` when there is no dedicated resource — Zola, +Hugo, or any other generator, or a pre-built SPA. + +## Where next + +- [Websites on AWS](/aws/frontend/websites) — the full websites surface, including + server-side rendering +- [StaticSite reference](/providers/aws/website/staticsite) and + [Router reference](/providers/aws/website/router) — every prop and attribute +- [AWS on Alchemy](/aws) — the AWS provider hub diff --git a/.agents/skills/alchemy/references/aws/frontend/sveltekit.md b/.agents/skills/alchemy/references/aws/frontend/sveltekit.md new file mode 100644 index 00000000..13a7a171 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/frontend/sveltekit.md @@ -0,0 +1,177 @@ + + +# SvelteKit + +> Deploy a SvelteKit app to AWS with AWS.Website.SvelteKit — SSR on a streaming Lambda Function URL, assets and prerendered pages on S3 + CloudFront, and Kit's own dev server under alchemy dev. + +`AWS.Website.SvelteKit` deploys a +[SvelteKit](https://svelte.dev/docs/kit) app to AWS. It builds the +app with SvelteKit's own Vite pipeline and a wrangler-free in-memory +AWS adapter emitting a streaming Lambda handler. Client assets and +prerendered pages are served from a private S3 bucket through +CloudFront; dynamic routes stream from the Lambda Function URL. Your +`vite.config.ts` loads natively; there is no `svelte.config.js` to +write (kit v3 dropped it) and no adapter to install. + +## Install + +The build integration is not bundled with alchemy. Install +`@alchemy.run/frontend-frameworks`; the resource loads its +`/sveltekit` and `/sveltekit/aws` exports from your project at deploy +time. It is only used at build time, so a dev dependency is enough: + +```sh +bun add -d @alchemy.run/frontend-frameworks +``` + +## Configure SvelteKit + +Your project's `vite.config.ts` loads natively — your Vite plugins +and the kit options in your `sveltekit(...)` call all apply as usual. +Alchemy injects its own wrangler-free AWS adapter (replacing any +adapter you declare, with a warning), so a fresh SvelteKit project +deploys as-is. Deploy-specific kit overrides can also be passed via +the `kit` prop, which merges over your `sveltekit(...)` options — see +[Kit options](#kit-options) below. A project without a +`vite.config.*` works too: the resource falls back to a fully +programmatic build. + +## Declare the Website + +Declare the site as a module-level const (rather than inline in the +Stack): + +```typescript +// alchemy.run.ts +import * as AWS from "alchemy/AWS"; + +export const Website = AWS.Website.SvelteKit("Website"); +``` + +## Add it to the Stack + +Yield the site from your Stack and return its URL: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "MySvelteKitSite", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { + const site = yield* Website; + return { url: site.url }; + }), +); +``` + +Routes with `export const prerender = true` are uploaded to S3 and +served from the edge without invoking the server; server routes +(`+server.ts`) and server load functions stream from the Lambda. + +See +[examples/aws-website-sveltekit](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-website-sveltekit) +for the checked-in example. + +## Add environment variables + +The server function's environment is configured under `env` — plain +values, or outputs from other resources in the Stack: + +```diff lang="typescript" +// alchemy.run.ts +export const Website = AWS.Website.SvelteKit("Website", { ++ env: { ++ GREETING: "Hello from alchemy", ++ API_BASE: api.url, ++ }, +}); +``` + +The values are set on the Lambda at deploy time and injected into the +dev server's process environment under `alchemy dev`, so server code +reads the same values in both modes. + +## Read the environment in server code + +The Kit server runs in a plain Node Lambda, so server routes read the +environment from `process.env` (or kit's `$env/dynamic/private`, +which is initialized from it): + +```typescript +// src/routes/+page.server.ts +export const load = () => { + return { greeting: process.env.GREETING ?? "hello" }; +}; +``` + +## Kit options + +Since kit v3 there is no `svelte.config.js` — kit options live in the +`sveltekit(...)` call in your `vite.config.ts`, which loads natively: + +```typescript +// vite.config.ts +import { sveltekit } from "@sveltejs/kit/vite"; +import tailwindcss from "@tailwindcss/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + tailwindcss(), + sveltekit({ + alias: { $lib: "src/lib" }, + }), + ], +}); +``` + +The `kit` prop on the resource is the deploy-time override bag: +JSON-serializable kit config merged over those options (the prop +wins). Use it for per-stage values or alchemy `Output`s your config +file can't compute: + +```typescript +// alchemy.run.ts +export const Website = AWS.Website.SvelteKit("Website", { + kit: { + paths: { base: "/docs" }, + }, +}); +``` + +No functions or plugins in the bag — `preprocess`, `vitePlugin`, and +the other construction-time options belong in your `sveltekit(...)` +call. Don't set `kit.adapter` — Alchemy injects its own wrangler-free +AWS adapter (an adapter declared in your `sveltekit(...)` call is +replaced with a warning). + +## Local dev + +```sh +bun alchemy dev +``` + +`alchemy dev` runs SvelteKit's own Vite dev server — Node SSR with +full HMR, which is already the AWS Lambda programming model +(`process.env` included) — instead of deploying; `site.url` is the +local address and no AWS resources are created. Wrap the site in +`Alchemy.remote()` to deploy the real infrastructure even during dev. + +## Custom domain + +```typescript +const site = yield* AWS.Website.SvelteKit("Web", { + domain: { name: "app.example.com" }, +}); +``` + +The hosted zone is inferred from the hostname. Pass `hostedZoneId` to +pin it when several zones could match. diff --git a/.agents/skills/alchemy/references/aws/frontend/tanstack-start.md b/.agents/skills/alchemy/references/aws/frontend/tanstack-start.md new file mode 100644 index 00000000..6958c610 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/frontend/tanstack-start.md @@ -0,0 +1,302 @@ + + +# TanStack Start + +> Deploy TanStack Start (React or Solid) to AWS with AWS.Website.TanStackStart — SSR on a streaming Lambda Function URL, client assets on S3 + CloudFront, and TanStack Start's own Vite dev server under alchemy dev. + +`AWS.Website.TanStackStart` deploys a +[TanStack Start](https://tanstack.com/start) app to AWS. TanStack Start +is pure Vite, so Alchemy runs your project's own `vite build`: the SSR +server runs on a Lambda Function URL with response streaming, and client +assets are served from a private S3 bucket through CloudFront. A +CloudFront Function routes each request at the edge — uploaded files go +to S3, everything else streams from the Lambda. + +## Install + +The build integration is not bundled with alchemy. Install +`@alchemy.run/frontend-frameworks`; the resource loads its +`/tanstack-start` and `/tanstack-start/aws` exports from your project at +deploy time. It is only used at build time, so a dev dependency is +enough: + +```sh +bun add -d @alchemy.run/frontend-frameworks +``` + +## Configure Vite + +Your `vite.config.ts` stays exactly what TanStack Start's scaffold gives +you — no adapter, no deployment preset: + +```typescript +// vite.config.ts +import { tanstackStart } from "@tanstack/react-start/plugin/vite"; +import viteReact from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [tanstackStart(), viteReact()], +}); +``` + +Alchemy drives that config's `builder.buildApp()` and forces the SSR +bundle to be self-contained, then wraps its fetch handler as a streaming +Lambda handler. + +:::caution[Remove @cloudflare/vite-plugin if present] +The TanStack CLI adds `@cloudflare/vite-plugin` when scaffolding a new +app. It builds for workerd, not Node, so an AWS deploy needs it gone: + +```diff lang="typescript" +// vite.config.ts +-import { cloudflare } from "@cloudflare/vite-plugin"; +import { tanstackStart } from "@tanstack/react-start/plugin/vite"; +import viteReact from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ +- plugins: [ +- cloudflare({ viteEnvironment: { name: "ssr" } }), +- tanstackStart(), +- viteReact(), +- ], ++ plugins: [tanstackStart(), viteReact()], +}); +``` + +::: + +## Declare the Website + +Declare the site as a module-level const (rather than inline in the +Stack): + +```typescript +// alchemy.run.ts +import * as AWS from "alchemy/AWS"; + +export const Website = AWS.Website.TanStackStart("Website"); +``` + +`rootDir` is the project root — the directory holding `package.json` +and `vite.config.*`. It defaults to `"."`, so set it only when the app +does not sit next to `alchemy.run.ts`: + +```typescript +export const Website = AWS.Website.TanStackStart("Website", { + rootDir: "./app", +}); +``` + +## Add it to the Stack + +Yield the site from your Stack and return its URL: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "MyTanStackSite", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { + const site = yield* Website; + return { url: site.url }; + }), +); +``` + +Requests matching a built client asset are served straight from S3; +everything else — SSR routes, server routes, server functions — falls +through to the Lambda. + +## Add environment variables + +The server function's environment is configured under `env`: + +```diff lang="typescript" +// alchemy.run.ts +export const Website = AWS.Website.TanStackStart("Website", { ++ memorySize: 2048, ++ env: { ++ API_BASE: "https://api.example.com", ++ }, +}); +``` + +`env` values are set on the Lambda on deploy and injected into +the dev server's process environment under `alchemy dev`, so server code +reads the same values in both modes. Sibling props tune the +Lambda itself (`memorySize`, `timeout`, `architecture`, `runtime`). + +An output from another resource only exists once that resource is +yielded, so declare the site inside the Stack generator instead: + +```typescript +// alchemy.run.ts +Effect.gen(function* () { + const api = yield* Api; + + const site = yield* AWS.Website.TanStackStart("Website", { + env: { API_BASE: api.url }, + }); + + return { url: site.url }; +}); +``` + +## Read the environment in a server function + +The TanStack Start server runs in a plain Node Lambda, so server +functions read the environment from `process.env`: + +```typescript +// src/routes/index.tsx +import { createServerFn } from "@tanstack/react-start"; + +const getApiBase = createServerFn({ method: "GET" }).handler(() => ({ + apiBase: process.env.API_BASE ?? "unset", +})); +``` + +Values prefixed `VITE_` are inlined into the client bundle at build time +instead, as `import.meta.env.VITE_*` — set those in the shell that runs +`alchemy deploy`, not in `env`. + +## Read the environment in a server route + +Server routes run on the same Lambda and read `process.env` the same +way: + +```typescript +// src/routes/api.hello.ts +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/api/hello")({ + server: { + handlers: { + GET: () => new Response(process.env.API_BASE ?? "unset"), + }, + }, +}); +``` + +## Call AWS services from server code + +The composite creates and owns the server Lambda's execution role, and +exposes no prop to add permissions to it. Server code that needs to +reach AWS goes behind its own Lambda, where capabilities are bound +explicitly: + +```typescript +// alchemy.run.ts +Effect.gen(function* () { + const api = yield* Api; + + const site = yield* AWS.Website.TanStackStart("Website", { + env: { API_BASE: api.url }, + }); + + return { url: site.url }; +}); +``` + +A server function then calls that API over HTTP with the URL it was +handed: + +```typescript +// src/routes/index.tsx +import { createServerFn } from "@tanstack/react-start"; + +const readObject = createServerFn({ method: "GET" }).handler(async () => { + const res = await fetch(`${process.env.API_BASE}/hello`); + return { body: await res.text() }; +}); +``` + +See [Effect HTTP API](/aws/apis/effect-http-api) for building that +Lambda and binding it to the resources it reads. + +## Build configuration + +The build runs through your project's own `vite.config.ts` — the +`tanstackStart()` plugin and everything else in it apply as-is. Keep +vite's default `build.outDir` (`dist`); the integration reads the +server and client output from `dist/server` and `dist/client`. + +## Local dev + +```sh +bun alchemy dev +``` + +`alchemy dev` runs TanStack Start's own Vite dev server (native HMR) +instead of deploying anything; `site.url` is the dev server's +`http://localhost:` address and no AWS resources are created. Wrap +the site in `Alchemy.remote()` to deploy the real infrastructure even +during dev. + +## Solid variant + +TanStack Start's Solid flavor works the same way — swap the plugins in +`vite.config.ts` and keep the identical `alchemy.run.ts`: + +```typescript +// vite.config.ts +import { tanstackStart } from "@tanstack/solid-start/plugin/vite"; +import { defineConfig } from "vite"; +import viteSolid from "vite-plugin-solid"; + +export default defineConfig({ + plugins: [tanstackStart(), viteSolid({ ssr: true })], +}); +``` + +`@tanstack/solid-start` exposes the same `tanstackStart()` plugin entry +point and the same `client`/`ssr` build environments, so the deploy is +identical. + +## Custom domain + +```typescript +// alchemy.run.ts +export const Website = AWS.Website.TanStackStart("Website", { + domain: { name: "app.example.com" }, +}); +``` + +The certificate is created in `us-east-1` (required by CloudFront), DNS +validation records and the alias record are managed through Route 53. +The hosted zone is inferred from the hostname — pass `hostedZoneId` to +pin it when several zones could match. + +## Share a distribution with Router + +CloudFront distributions take minutes to create. To serve several sites +(or a site plus an API) from one distribution, attach the site to an +[`AWS.Website.Router`](/aws/frontend/static-site#compose-sites-with-a-router): + +The site takes the Router's outputs, so both are yielded inside the +Stack generator: + +```typescript +// alchemy.run.ts +Effect.gen(function* () { + const router = yield* AWS.Website.Router("FrontDoor", { + domain: { name: "example.com" }, + }); + + const site = yield* AWS.Website.TanStackStart("Website", { + domain: { router }, + }); + + return { url: site.url }; +}); +``` diff --git a/.agents/skills/alchemy/references/aws/frontend/vinext.md b/.agents/skills/alchemy/references/aws/frontend/vinext.md new file mode 100644 index 00000000..f17bc212 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/frontend/vinext.md @@ -0,0 +1,136 @@ + + +# Vinext + +> Deploy vinext to AWS with the shared Website API, native local development, and a platform-specific data cache. + +`AWS.Website.Vinext` builds with the Vinext Vite API for production and runs native +`vinext dev` locally. The React Server Components handler runs on a streaming Lambda Function URL. +S3 stores assets and CloudFront routes asset requests to S3 and other requests +to Lambda. This uses vinext, not OpenNext. + +The Lambda target supports **App Router** applications. Use a Node deployment +target such as [Prisma](/prisma/frontend/vinext) or [Fly](/fly/frontend/vinext) +for Pages Router applications. + +## Install + +Keep `vinext`, `react`, `react-dom`, and `react-server-dom-webpack` in your +application dependencies, with `vite` and `@vitejs/plugin-rsc` for building. +Use vinext **1.0.0-beta.10 or newer**. Alchemy uses its standalone packaging +to include external runtime dependencies in the Lambda archive. Add the Alchemy integration: + + + + + + +## Configure vinext + +```typescript +// vite.config.ts +import { defineConfig } from "vite"; +import vinext from "vinext"; + +export default defineConfig({ + plugins: [vinext({ prerender: true })], +}); +``` + +Alchemy injects the target-specific data-cache adapter during deployment. +No Alchemy plugin is required in `vite.config.ts`; deployment settings belong on +`Website.Vinext`. Leave Cloudflare plugins and Worker entrypoints out of this application. + +## Declare the Website + +```typescript +// alchemy.run.ts +import * as AWS from "alchemy/AWS"; + +export const Website = AWS.Website.Vinext("Website", { + rootDir: ".", +}); +``` + +`rootDir`, `env`, `memo`, `assets`, `dev`, and `domain` use the same vocabulary +as the other Website constructors. Framework configuration stays in +`vite.config.ts` rather than a nested server configuration object. + +## Add it to the Stack + +```typescript +import * as Alchemy from "alchemy"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "MyVinextSite", + { providers: AWS.providers(), state: Alchemy.localState() }, + Effect.gen(function* () { + const site = yield* Website; + return { url: site.url }; + }), +); +``` + +Keep the local state available for subsequent deploys and teardown. + +## Add environment variables + +```typescript +export const Website = AWS.Website.Vinext("Website", { + env: { GREETING: "Hello from vinext on AWS!" }, +}); +``` + +`env` is available during build, development, and production. Server code reads +`process.env`; public framework variables may be compiled into browser assets. +Keep secrets out of public keys and wrap credentials in `Redacted`. + +## Read the runtime environment + +```typescript +// app/api/hello/route.ts +export function GET(request: Request) { + const name = new URL(request.url).searchParams.get("name") ?? "world"; + return Response.json({ name, greeting: process.env.GREETING ?? "hello" }); +} +``` + +Request-dependent route handlers read the production environment. A prerendered +page reflects its build-time inputs instead. + +## Configure the data cache + +The Website provisions an S3 bucket for ISR and `"use cache"`, sets +`CACHE_BUCKET_NAME`, and grants the Lambda access. The build selects the S3 +adapter. Local development uses memory and creates no bucket. + +## Local development + +```sh +bun alchemy dev +``` + +The Website runs `vinext dev` with native HMR and creates no cloud resources. +Other resources declared in the Stack retain their own provider behavior. +Apply `.pipe(Alchemy.remote())` to the Website for a live deployment during dev. + +## Custom domains + +```typescript +const site = yield* AWS.Website.Vinext("Web", { + domain: "app.example.com", +}); +``` + +See [Website domains](/aws/frontend/websites) for platform-specific DNS and +certificate configuration. + +## Where next + +- [vinext API reference](/providers/aws/website/vinext). +- [Runnable example](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-website-vinext). +- [Website overview](/aws/frontend/websites). +- [vinext on Cloudflare](/cloudflare/frontend/vinext) and [Prisma](/prisma/frontend/vinext). diff --git a/.agents/skills/alchemy/references/aws/frontend/vite-spa.md b/.agents/skills/alchemy/references/aws/frontend/vite-spa.md new file mode 100644 index 00000000..94287bf0 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/frontend/vite-spa.md @@ -0,0 +1,294 @@ + + +# React SPA + +> Deploy a React single-page app to AWS with AWS.Website.Vite — the Vite build on S3 + CloudFront, deep links answered by index.html out of the box, and Vite's own dev server under alchemy dev. + +This is the walkthrough for a plain React single-page app on +[`AWS.Website.Vite`](/aws/frontend/vite): scaffold the project, run +its own `vite build`, and serve the output from a private S3 bucket +through CloudFront. The composite is assets-only — it never creates a +Lambda — and `spa` defaults on, so client-side routes deep-link +correctly with nothing to configure. For the resource itself and +every prop, see the +[Vite resource page](/aws/frontend/vite). + +## Install + +The build integration is not bundled with alchemy. Install +`@alchemy.run/frontend-frameworks`; the resource loads its `/vite` and +`/vite/aws` exports from your project at deploy time. It is only used +at build time, so a dev dependency is enough: + +```sh +bun add -d @alchemy.run/frontend-frameworks +``` + +## Scaffold the app + +Start from Vite's official React + TypeScript template: + +```sh +bun create vite@latest web -- --template react-ts\ncd web && bun install && cd .. +``` + +That drops a complete project into `./web/` with its own +`index.html`, `package.json`, and `vite.config.ts`. + +## Configure Vite + +Your `vite.config.*` loads natively — plugins, aliases, and every +other non-serializable option work exactly as they do outside +Alchemy. The React template already registers the React plugin: + +```typescript +// web/vite.config.ts +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], +}); +``` + +There is no adapter to install and no build command to wire up: +Alchemy runs this config programmatically. + +## Declare the Website + +Declare the site as a module-level const (rather than inline in the +Stack). `rootDir` points at the project directory — the one holding +`index.html` and `vite.config.ts`: + +```typescript +// alchemy.run.ts +import * as AWS from "alchemy/AWS"; + +export const Website = AWS.Website.Vite("Website", { + rootDir: "./web", +}); +``` + +`rootDir` defaults to `"."`, so you only set it when the app isn't +next to your `alchemy.run.ts`. + +## Add it to the Stack + +Yield the site from your Stack and return its URL: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "MyReactApp", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { + const site = yield* Website; + return { url: site.url }; + }), +); +``` + +```sh +bun alchemy deploy +``` + +Alchemy runs `vite build` in `rootDir`, uploads `dist/` to a private +S3 bucket, and serves it through CloudFront. `site.url` is the +distribution's URL (or `https://` once you pass +`domain`). + +## Client-side routing + +Deep links work with no configuration. `spa` defaults to `true`, so a +request that matches no uploaded file is answered with the index page +and a `200` — the client router then handles the path. + +Add React Router and route on the client: + +```sh +bun add react-router-dom +``` + +```tsx +// web/src/main.tsx +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter, Link, Route, Routes } from "react-router-dom"; + +function App() { + return ( + + About + + Home} /> + About} /> + + + ); +} + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); +``` + +Loading `https:///about` directly serves `index.html` and +React Router renders the About page — no rewrite rule, no edge +function to write. + +Pretty URLs still win over the fallback: `/about` serves +`about.html` or `about/index.html` when the build produced one. + +## Serve a real 404 instead + +Multi-page Vite projects (an `index.html` per route) want a status +code, not the shell. Set `errorPage` and turn the SPA fallback off — +the two are mutually exclusive: + +```diff lang="typescript" +// alchemy.run.ts +export const Website = AWS.Website.Vite("Website", { + rootDir: "./web", ++ spa: false, ++ errorPage: "404.html", +}); +``` + +## Environment variables + +A pure SPA has no server, so anything the client reads is inlined +into the bundle at build time. That is plain Vite: put `VITE_`-prefixed +keys in a `.env` file at the project root and read them from +`import.meta.env`. + +```sh +# web/.env +VITE_API_URL=https://api.example.com +``` + +```tsx +// web/src/api.ts +const apiUrl = import.meta.env.VITE_API_URL; + +export const hello = () => fetch(`${apiUrl}/hello`); +``` + +Only `VITE_`-prefixed keys reach the client bundle. A committed +`.env` is hashed with the rest of the project, so editing one re-runs +the build on the next deploy. A gitignored one (`.env.local`) is not +— see [Skip unchanged builds](#skip-unchanged-builds) for how to +widen the hash. + +## Inject another resource's URL + +`AWS.Website.Vite` takes no server environment (there is no server), +so a value that only exists after another resource deploys — an API's +Function URL, a bucket name — can't be passed to the build through +the composite. Deploy those apps with +[`AWS.Website.StaticSite`](/aws/frontend/static-site) instead, which +runs the build as a command and passes `build.env` to it: + +```typescript +// alchemy.run.ts +Effect.gen(function* () { + const api = yield* Api; + + const site = yield* AWS.Website.StaticSite("Website", { + path: "./web", + build: { + command: "npm run build", + output: "dist", + env: { + VITE_API_URL: api.url, + }, + }, + spa: true, + }); + + return { url: site.url }; +}); +``` + +Outputs like `api.url` resolve before the build command runs, so Vite +inlines the deployed URL exactly as it would a `.env` value. + +## Skip unchanged builds + +Every file under `rootDir` (gitignore rules applied) plus the nearest +lockfile is content-hashed. A deploy whose inputs are unchanged skips +the build and the upload entirely. Narrow or widen the hash with +`memo`, or set `memo: false` to rebuild on every deploy: + +```diff lang="typescript" +// alchemy.run.ts +export const Website = AWS.Website.Vite("Website", { + rootDir: "./web", ++ memo: { ++ include: ["src/**", "index.html", "package.json"], ++ lockfile: true, ++ }, +}); +``` + +Setting `include` (or `exclude`) drops the lockfile from the hash — +pass `lockfile: true` to keep rebuilding when dependencies change. + +## Local dev + +```sh +bun alchemy dev +``` + +`alchemy dev` runs Vite's own dev server — native HMR, instant module +updates — instead of deploying. `site.url` is the local address and +no AWS resources are created at all: no bucket, no distribution. + +The dev server picks an ephemeral port. Pin it when you want the +local URL stable across runs: + +```diff lang="typescript" +// alchemy.run.ts +export const Website = AWS.Website.Vite("Website", { + rootDir: "./web", ++ dev: { port: 3000 }, +}); +``` + +Wrap the site in `Alchemy.remote()` to deploy the real S3 + +CloudFront infrastructure even during dev. + +## Custom domain + +```typescript +// alchemy.run.ts +export const Website = AWS.Website.Vite("Website", { + rootDir: "./web", + domain: { name: "app.example.com" }, +}); +``` + +The certificate and Route 53 records are managed for you — the hosted +zone is inferred from the hostname, and `hostedZoneId` pins it when +several zones could match. To serve +several sites from one front door, attach to an +[`AWS.Website.Router`](/aws/frontend/static-site#compose-sites-with-a-router) +with `domain: { router, path: "/app" }` instead. + +## Where next + +- [Vite on AWS](/aws/frontend/vite) — the resource reference: every + prop, the `vite` override bag, invalidation, and Router composition +- [Websites on AWS](/aws/frontend/websites) — the full websites + surface, including server-side rendering +- [Static sites](/aws/frontend/static-site) — the build-command path + for any pre-built output diff --git a/.agents/skills/alchemy/references/aws/frontend/vite.md b/.agents/skills/alchemy/references/aws/frontend/vite.md new file mode 100644 index 00000000..1246e31d --- /dev/null +++ b/.agents/skills/alchemy/references/aws/frontend/vite.md @@ -0,0 +1,310 @@ + + +# Vite + +> Deploy a plain Vite app to AWS with AWS.Website.Vite — the vite build output on S3 behind CloudFront, SPA fallback at the edge, and Vite's own dev server under alchemy dev. + +`AWS.Website.Vite` deploys a client-only [Vite](https://vite.dev) +project to AWS. It runs your project's own `vite build` — your +`vite.config.*` loads natively, plugins included — and serves the +output from a private S3 bucket through CloudFront. The site is +assets-only: no Lambda is created, no CloudFormation to write, no +build command to wire up. + +## Install + +The build integration is not bundled with alchemy. Install +`@alchemy.run/frontend-frameworks`; the resource loads its `/vite` and +`/vite/aws` exports from your project at deploy time. It is only used +at build time, so a dev dependency is enough: + +```sh +bun add -d @alchemy.run/frontend-frameworks +``` + +## Configure Vite + +There is nothing to configure. Your `vite.config.*` loads natively — +plugins, aliases, and any other non-serializable options work exactly +as they do outside Alchemy: + +```typescript +// vite.config.ts +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], +}); +``` + +A project with no config file at all works too: an `index.html` next +to your `alchemy.run.ts` is enough. + +## Declare the Website + +Declare the site as a module-level const (rather than inline in the +Stack): + +```typescript +// alchemy.run.ts +import * as AWS from "alchemy/AWS"; + +export const Website = AWS.Website.Vite("Website"); +``` + +## Add it to the Stack + +Yield the site from your Stack and return its URL: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "MyViteSite", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { + const site = yield* Website; + return { url: site.url }; + }), +); +``` + +Deploying uploads the build output to S3, writes the file manifest to +a CloudFront KeyValueStore, and creates the distribution that serves +it at the edge. + +```sh +bun alchemy deploy +``` + +See +[examples/aws-website-vite](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-website-vite) +for the checked-in example. + +## Point at a subdirectory + +`rootDir` is the Vite project root — the directory holding +`package.json`, `vite.config.*`, and `index.html`. It defaults to the +directory you run `alchemy` from: + +```typescript +export const Website = AWS.Website.Vite("Website", { + rootDir: "./web", +}); +``` + +Vite resolves `index.html` relative to its root, so `index.html` must +live directly under `rootDir`. See Vite's +[project structure docs](https://vite.dev/guide/#index-html-and-project-root). + +## Deploy-time overrides + +Your `vite.config.*` is the home for Vite configuration. The `vite` +bag is for the exception: a value decided by the deployment rather +than the project — config files can't consume alchemy `Output`s. +Overrides merge *over* the config file, so the deploy-time value wins: + +```typescript +export const Website = AWS.Website.Vite("Docs", { + vite: { + base: "/docs/", + outDir: "build", + }, +}); +``` + +`base` is the public path the site deploys under; `outDir` is the +build output directory, relative to `rootDir`. Leave the bag unset and +the resolved values from your `vite.config.*` are used — Vite's own +`outDir` default is `dist`. `base` also applies to the dev server +under `alchemy dev`. + +To load a different config file entirely, pass `config` (relative to +`rootDir`): + +```typescript +export const Website = AWS.Website.Vite("Web", { + config: "vite.deploy.config.ts", +}); +``` + +## SPA fallback and 404 pages + +A miss — a request that matches no uploaded file — can be answered two +ways, and they are mutually exclusive. Plain Vite apps are usually +single-page apps, so `spa` defaults to `true`: misses serve the index +page with a `200` and the client-side router takes over. + +For a multi-page project (an `index.html` per route), turn it off and +point at the built error page instead: + +```diff lang="typescript" +export const Website = AWS.Website.Vite("Docs", { ++ spa: false, ++ errorPage: "404.html", +}); +``` + +`errorPage` serves that file with a real `404` status. Pretty URLs +work in both modes: `/about` serves `about.html` or +`about/index.html` when one was uploaded, resolved at the edge from +the file manifest. + +## Environment variables + +There is no server, so everything the app reads is inlined into the +bundle at build time — plain Vite semantics. Put `VITE_`-prefixed keys +in a `.env` file at `rootDir` and read them from `import.meta.env`: + +```sh +# .env +VITE_API_URL=https://api.example.com +``` + +```typescript +// src/api.ts +const apiUrl = import.meta.env.VITE_API_URL; +``` + +Only `VITE_`-prefixed keys reach the bundle. A committed `.env` is +hashed with the rest of the project, so editing one re-runs the build +on the next deploy. + +The resource takes no `env` prop, so a value that only exists +after another resource deploys — an API's Function URL, a bucket name +— cannot reach this build. Deploy those apps with +[`AWS.Website.StaticSite`](/aws/frontend/static-site) instead, which +runs the build as a command and passes `environment` to it. + +## Rebuilds are content-hashed + +Every non-gitignored file under `rootDir` plus the nearest lockfile is +content-hashed. If nothing changed, both the build and the deploy are +skipped. Narrow the scope with `memo` when the project holds large +directories that don't affect the build output: + +```typescript +export const Website = AWS.Website.Vite("Docs", { + memo: { + include: ["src/**", "index.html", "package.json"], + lockfile: true, + }, +}); +``` + +Setting `include` flips the lockfile default to `false`, so pair it +with `lockfile: true` to keep rebuilding when dependencies change. +`memo: false` rebuilds on every deploy. See +[Memoization](/command/memoization) for the details. + +## Cache invalidation on deploy + +Every deploy that changes your files invalidates `/*` and moves on +without waiting. Tune it with `invalidation`: + +```typescript +export const Website = AWS.Website.Vite("Website", { + invalidation: { + paths: "versioned", + wait: true, + }, +}); +``` + +`paths: "versioned"` invalidates only the index page, for builds whose +assets are content-hashed and immutable; `invalidation: false` skips +it entirely. + +## Local dev + +```sh +bun alchemy dev +``` + +`alchemy dev` runs Vite's own dev server (native HMR) instead of +deploying; `site.url` is the local address and no AWS resources are +created. Wrap the site in `Alchemy.remote()` to deploy the real +infrastructure even during dev. + +The dev server picks an ephemeral port by default. Pin it to keep the +local URL stable across runs: + +```typescript +export const Website = AWS.Website.Vite("Website", { + dev: { port: 3000 }, +}); +``` + +## Custom domain + +```typescript +// alchemy.run.ts +export const Website = AWS.Website.Vite("Website", { + domain: { name: "app.example.com" }, +}); +``` + +The ACM certificate and Route 53 records are managed for you — the +hosted zone is inferred from the hostname, and `hostedZoneId` pins it +when several zones could match. A bare string is shorthand for +`{ name }`. + +## Share a CloudFront Router + +Attach the site to an existing +[`AWS.Website.Router`](/aws/frontend/static-site#compose-sites-with-a-router) +to serve it through one shared distribution instead of its own: + +The site takes the Router's *outputs*, so both are yielded inside the +Stack generator: + +```typescript +Effect.gen(function* () { + const router = yield* AWS.Website.Router("Router", { + domain: { name: "example.com" }, + }); + + const site = yield* AWS.Website.Vite("Docs", { + domain: { router, path: "/docs" }, + }); + + return { url: site.url }; +}); +``` + +## Frameworks + +Anything whose whole deployable output is a `vite build` deploys with +this resource. Each client-only stack has its own page building on it: + +- [React SPA](/aws/frontend/vite-spa) — from a bare `index.html` to + the `create-vite` template. +- [Vue](/aws/frontend/vue) — Vue 3 SPAs with `vue-router` deep links. +- [Foldkit](/aws/frontend/foldkit) — builds through the same Vite + pipeline under its own `AWS.Website.Foldkit` resource. + +## SSR frameworks + +`AWS.Website.Vite` is static-only — it never creates a server +function. Frameworks that wrap Vite with a server half deploy through +their own resources, which build the SSR bundle and put it on a +streaming Lambda Function URL: +[Astro](/aws/frontend/astro), +[SvelteKit](/aws/frontend/sveltekit), +[Octane](/aws/frontend/octane), +[Nuxt](/aws/frontend/nuxt), +[Next.js](/aws/frontend/nextjs), +[TanStack Start](/aws/frontend/tanstack-start), +[React Router](/aws/frontend/react-router), and +[Waku](/aws/frontend/waku). + +For any other build command that emits a directory of files, use +[StaticSite](/aws/frontend/static-site). diff --git a/.agents/skills/alchemy/references/aws/frontend/vue.md b/.agents/skills/alchemy/references/aws/frontend/vue.md new file mode 100644 index 00000000..dff59d66 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/frontend/vue.md @@ -0,0 +1,240 @@ + + +# Vue + +> Deploy a Vue single-page app to AWS with AWS.Website.Vite — S3 + CloudFront, deep links that work by default, and Vue's own Vite dev server under alchemy dev. + +[Vue](https://vuejs.org) is pure Vite — the whole app builds from +`@vitejs/plugin-vue` in your `vite.config.ts`, so +[`AWS.Website.Vite`](/aws/frontend/vite) deploys it with a single +declaration. The build output goes to a private S3 bucket behind a +CloudFront distribution; there is no server function, no build command +to wire up, and no CloudFormation to write. + +## Install + +The build integration is not bundled with alchemy. Install +`@alchemy.run/frontend-frameworks`; the resource loads its `/vite` and +`/vite/aws` exports from your project at deploy time. It is only used at +build time, so a dev dependency is enough: + +```sh +bun add -d @alchemy.run/frontend-frameworks +``` + +## Scaffold the app + +Start from Vite's official Vue + TypeScript template: + +```sh +bun create vite@latest my-vue-app -- --template vue-ts\ncd my-vue-app && bun install +``` + +Write `alchemy.run.ts` in that project root, next to `index.html` and +`vite.config.ts`. `rootDir` defaults to `"."`, so you only set it when the +app lives somewhere else. + +## Configure Vite + +Your Vite config stays exactly what the template gives you: + +```typescript +// vite.config.ts +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; + +export default defineConfig({ + plugins: [vue()], +}); +``` + +Alchemy runs your project's own `vite build` programmatically against this +config — plugins, aliases, and the rest of your setup are preserved as-is. + +## Declare the Website + +Declare the site as a module-level const (rather than inline in the +Stack) — every prop is optional, so the minimal declaration is just a +name: + +```typescript +// alchemy.run.ts +import * as AWS from "alchemy/AWS"; + +export const Website = AWS.Website.Vite("Vue"); +``` + +## Add it to the Stack + +Yield the site from your Stack and return its URL: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "MyVueSite", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { + const site = yield* Website; + return { url: site.url }; + }), +); +``` + +```sh +bun alchemy deploy +``` + +Alchemy runs `vite build`, uploads `dist` to S3, and serves it from +CloudFront at the returned `url`. Input files are content-hashed, so a +deploy that changes nothing skips the build and the upload. + +## Deep links with vue-router + +`vue-router` in history mode (`createWebHistory`) sends a deep link like +`/about` to the server as a request for a file that doesn't exist. +`AWS.Website.Vite` answers those misses with the index page and a `200`, +so the router resolves the route on the client — that is the `spa` +default and there is nothing to configure. + +```sh +bun add vue-router +``` + +```typescript +// src/main.ts +import { createApp } from "vue"; +import { createRouter, createWebHistory } from "vue-router"; +import App from "./App.vue"; +import About from "./About.vue"; +import Home from "./Home.vue"; + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: "/", component: Home }, + { path: "/about", component: About }, + ], +}); + +createApp(App).use(router).mount("#app"); +``` + +Loading `https:///about` directly serves the index page and +vue-router renders the About view. Pretty URLs still win over the +fallback: `/about` serves `about.html` or `about/index.html` when the +build produced one. + +For a multi-page project (one `index.html` per route), turn the fallback +off and serve a real 404 instead: + +```diff lang="typescript" +// alchemy.run.ts +export const Website = AWS.Website.Vite("Vue", { ++ spa: false, ++ errorPage: "404.html", +}); +``` + +`spa` and `errorPage` are mutually exclusive: one answers misses with the +index page at `200`, the other with your built error page at `404`. + +## Add environment variables + +A pure SPA has no server, so anything the app needs is baked into the +bundle at build time. Vite inlines `VITE_`-prefixed variables from your +project's `.env` files and from the environment the deploy runs in: + +```sh +# .env.production +VITE_API_URL=https://api.example.com +``` + +Type it for your Vue code with Vite's standard `ImportMetaEnv` +augmentation: + +```typescript +// src/vite-env.d.ts +/// + +interface ImportMetaEnv { + readonly VITE_API_URL: string; +} +``` + +Client code reads it as `import.meta.env.VITE_API_URL`. + +## Bake a Stack output into the bundle + +To inline a value that only exists after another resource deploys — an +API's URL, a bucket name — build the site with +[`AWS.Website.StaticSite`](/aws/frontend/static-site), whose +`build.env` is passed to the build command: + +```typescript +// alchemy.run.ts +export default Alchemy.Stack( + "MyVueSite", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { + const api = yield* Api; + + const site = yield* AWS.Website.StaticSite("Vue", { + build: { + command: "vite build", + output: "dist", + env: { + VITE_API_URL: api.url, + }, + }, + spa: true, + }); + + return { url: site.url }; + }), +); +``` + +The build re-runs when the inputs change and the output deploys to the +same S3 + CloudFront stack. + +## Local dev + +```sh +bun alchemy dev +``` + +`alchemy dev` runs Vue's own Vite dev server with native HMR instead of +deploying; `site.url` is the local address and no AWS resources are +created. Wrap the site in `Alchemy.remote()` to deploy the real +infrastructure even during dev. + +## Custom domain + +```typescript +// alchemy.run.ts +export const Website = AWS.Website.Vite("Vue", { + domain: { name: "app.example.com" }, +}); +``` + +The ACM certificate and the Route 53 records are created for you, and +`site.url` becomes `https://app.example.com`. The hosted zone is +inferred from the hostname — pass `hostedZoneId` to pin it when +several zones could match. + +## Where next + +- [Vite on AWS](/aws/frontend/vite) — every prop the resource takes, + including `vite.outDir`, `vite.base`, and Router composition +- [Websites on AWS](/aws/frontend/websites) — the full websites surface diff --git a/.agents/skills/alchemy/references/aws/frontend/waku.md b/.agents/skills/alchemy/references/aws/frontend/waku.md new file mode 100644 index 00000000..e842bb7b --- /dev/null +++ b/.agents/skills/alchemy/references/aws/frontend/waku.md @@ -0,0 +1,168 @@ + + +# Waku + +> Deploy a Waku app to AWS with AWS.Website.Waku — RSC server on a streaming Lambda Function URL, SSG pages and assets on S3 + CloudFront, and Waku's own dev server under alchemy dev. + +`AWS.Website.Waku` deploys a [Waku](https://waku.gg) app (React +Server Components) to AWS. It builds the project programmatically: +the RSC server runs on a Lambda Function URL with response streaming, +and the client output — including SSG-prerendered pages — is served +from a private S3 bucket through CloudFront. No `waku.config.ts` +edits are required — if you have one it loads natively — and there is +no adapter to configure and no build command to run. + +## Install + +The build integration is not bundled with alchemy. Install +`@alchemy.run/frontend-frameworks`; the resource loads its `/waku` +and `/waku/aws` exports from your project at deploy time. It is only +used at build time, so a dev dependency is enough: + +```sh +bun add -d @alchemy.run/frontend-frameworks +``` + +## Configure Waku + +No framework config is required — a fresh Waku project deploys +as-is. If your project has a `waku.config.ts`, Alchemy loads it +natively (exactly as Waku's own CLI does) and uses it as the base +config — including Vite plugins under its `vite` field: + +```typescript +// waku.config.ts +import { defineConfig } from "waku/config"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + basePath: "/", + vite: { + plugins: [tsconfigPaths()], + }, +}); +``` + +`waku.config.*` is the home for static configuration. For deploy-time +values — anything that varies by stage — the `waku` prop merges +overrides over the file, per key: + +```typescript +export const Website = AWS.Website.Waku("Website", { + waku: { + basePath: "/docs/", + }, +}); +``` + +The one key Alchemy owns is `unstable_adapter` — setting it fails the +build with an actionable error. + +:::note +Vite configuration must live in `waku.config.ts`'s `vite` field — +a standalone `vite.config.ts` is not loaded, same as Waku's own CLI. +::: + +## Declare the Website + +Declare the site as a module-level const (rather than inline in the +Stack): + +```typescript +// alchemy.run.ts +import * as AWS from "alchemy/AWS"; + +export const Website = AWS.Website.Waku("Website"); +``` + +## Add it to the Stack + +Yield the site from your Stack and return its URL: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "MyWakuSite", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { + const site = yield* Website; + return { url: site.url }; + }), +); +``` + +Pages rendered as `"static"` are generated at build time, uploaded +to S3, and served from the edge; dynamic pages and API routes stream +from the Lambda. + +See +[examples/aws-website-waku](https://github.com/alchemy-run/alchemy/tree/main/examples/aws-website-waku) +for the checked-in example. + +## Add environment variables + +The server function's environment is configured under `env` — plain +values, or outputs from other resources in the Stack: + +```diff lang="typescript" +// alchemy.run.ts +export const Website = AWS.Website.Waku("Website", { ++ env: { ++ GREETING: "Hello from alchemy", ++ API_BASE: api.url, ++ }, +}); +``` + +The values are set on the Lambda at deploy time and injected into the +dev server's process environment under `alchemy dev`, so server code +reads the same values in both modes. + +## Read the environment in server code + +Server components and API routes read the environment through waku's +`getEnv` — on AWS it is backed by the Lambda's `process.env` at +request time, and it keeps page modules portable across deploy +targets: + +```typescript +// src/pages/index.tsx +import { getEnv } from "waku"; + +export default async function HomePage() { + const greeting = getEnv("GREETING") ?? "hello"; + return

{greeting}

; +} + +export const getConfig = async () => ({ render: "dynamic" }) as const; +``` + +## Local dev + +```sh +bun alchemy dev +``` + +`alchemy dev` runs Waku's own dev server (native HMR) instead of +deploying; `site.url` is the local address and no AWS resources are +created. Wrap the site in `Alchemy.remote()` to deploy the real +infrastructure even during dev. + +## Custom domain + +```typescript +const site = yield* AWS.Website.Waku("Web", { + domain: { name: "app.example.com" }, +}); +``` + +The hosted zone is inferred from the hostname. Pass `hostedZoneId` to +pin it when several zones could match. diff --git a/.agents/skills/alchemy/references/aws/frontend/websites.md b/.agents/skills/alchemy/references/aws/frontend/websites.md new file mode 100644 index 00000000..a9820737 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/frontend/websites.md @@ -0,0 +1,127 @@ + + +# Websites + +> Deploy Vite, Astro, Next.js, vinext, Nuxt, React Router, SvelteKit, TanStack Start, Waku, Octane, or any static build to AWS with first-class Website resources. + +Alchemy deploys frontends to AWS with a family of `AWS.Website` +resources. Each one builds your project programmatically and deploys +it as a serverless site — the SSR server on a streaming Lambda +Function URL, static assets in a private S3 bucket, and a CloudFront +distribution whose edge router serves uploaded files from S3 and +forwards everything else to the server — with no AWS-specific +configuration: no CDK, no CloudFormation, no adapter setup. Your +framework's own config file (`astro.config.*`, `nuxt.config.ts`, +`vite.config.ts`, ...) loads natively; Alchemy layers its AWS +integration on top. + +- [`Vite`](/aws/frontend/vite) — any client-only Vite project (React, + Vue, Solid, or a plain SPA): assets-only, no Lambda; your + `vite.config.*` loads natively. +- [`Foldkit`](/aws/frontend/foldkit) — Foldkit apps; `Vite` with SPA + deep links on by default. +- [`Astro`](/aws/frontend/astro) — Astro sites, server-rendered or + fully static; your `astro.config.*` loads natively. +- [`Nextjs`](/aws/frontend/nextjs) — Next.js apps built through the + OpenNext (`@opennextjs/aws`) pipeline, with streaming SSR, image + optimization, and ISR wiring; your `next.config.*` is honored + as-is. +- [`Vinext`](/aws/frontend/vinext) — vinext (Next.js API on Vite) + apps on a streaming Lambda; your `vite.config.ts` loads natively. +- [`Nuxt`](/aws/frontend/nuxt) — Nuxt apps built through nitro's + `aws-lambda` preset; your `nuxt.config.ts` loads natively. +- [`ReactRouter`](/aws/frontend/react-router) — React Router v7 in + framework mode, built through your own `vite build`. +- [`SvelteKit`](/aws/frontend/sveltekit) — SvelteKit apps with a + wrangler-free in-memory AWS adapter; your `vite.config.ts` loads + natively. +- [`TanStackStart`](/aws/frontend/tanstack-start) — TanStack Start + (React or Solid), built through your own `vite build`. +- [`Waku`](/aws/frontend/waku) — Waku (React Server Components) + apps. +- [`Octane`](/aws/frontend/octane) — OctaneJS fullstack apps built + through your own `vite build` with the AWS marker adapter. +- [`StaticSite`](/aws/frontend/static-site) — any directory of files, + optionally produced by a build command, for static generators like + Zola and Hugo or pre-built SPAs. +- [`Router`](/aws/frontend/static-site#compose-sites-with-a-router) — + a shared CloudFront front door: one distribution serving several + sites (or a site plus an API), routed at the edge. + +Every resource shares the same surface: `domain` (ACM certificate + +Route 53 records), server configuration (`memorySize`, `timeout`, +`env`), `assets`, `edge` customizations, and `invalidation`. +All builds are memoized by content-hashing the input files — an +unchanged project skips the build and deploy entirely. Under +`alchemy dev`, every resource runs the framework's own dev server +(native HMR) instead of deploying; `Alchemy.remote()` opts back into +the full live deployment. + +## What's supported + +| Framework | Resource | Guide | +| --- | --- | --- | +| React / Vite SPA | `Vite` | [React SPA](/aws/frontend/vite-spa) | +| Vue | `Vite` | [Vue](/aws/frontend/vue) | +| Foldkit | `Foldkit` | [Foldkit](/aws/frontend/foldkit) | +| TanStack Start (React & Solid) | `TanStackStart` | [TanStack Start](/aws/frontend/tanstack-start) | +| React Router | `ReactRouter` | [React Router](/aws/frontend/react-router) | +| Astro | `Astro` | [Astro](/aws/frontend/astro) | +| Next.js | `Nextjs` | [Next.js](/aws/frontend/nextjs) | +| vinext | `Vinext` | [vinext](/aws/frontend/vinext) | +| Nuxt | `Nuxt` | [Nuxt](/aws/frontend/nuxt) | +| SvelteKit | `SvelteKit` | [SvelteKit](/aws/frontend/sveltekit) | +| Waku | `Waku` | [Waku](/aws/frontend/waku) | +| OctaneJS (fullstack) | `Octane` | [Octane](/aws/frontend/octane) | +| Zola, Hugo, or any static generator | `StaticSite` | [Static sites](/aws/frontend/static-site) | + +Every row is backed by a checked-in example or a live deploy test in +the Alchemy repository. The last row is deliberately open-ended: +`StaticSite` deploys any directory of files (running your build +command first if you give it one), so any generator works the same +way. + +## How to choose + +Use the resource named after your framework. `Astro`, `Nextjs`, +`Nuxt`, `ReactRouter`, `SvelteKit`, `TanStackStart`, +`Waku`, and `Octane` each drive their framework's own build and know +its output layout, config surface, and dev server. + +Use `Vite` when the app is client-only and a single `vite build` +produces the whole thing — a React or Vue SPA, or any Vite project +with no server. It creates no Lambda. + +Use `StaticSite` when the output is plain files from an arbitrary +build command that emits a directory (Zola, Hugo, or any other +generator without a dedicated resource). + +Use `Router` when several sites (or a site plus an API) should share +one CloudFront distribution and one domain — distributions take +minutes to create, and each custom domain can only attach to one. + +## Where next + +- [The Vite resource](/aws/frontend/vite) — build model, env + inlining, SPA fallback, dev mode. +- [The StaticSite resource](/aws/frontend/static-site) — build + commands, SPA/404 handling, Router composition, invalidation. +- Framework guides: + [React SPA](/aws/frontend/vite-spa), + [Vue](/aws/frontend/vue), + [Foldkit](/aws/frontend/foldkit), + [TanStack Start](/aws/frontend/tanstack-start), + [React Router](/aws/frontend/react-router), + [Astro](/aws/frontend/astro), + [Next.js](/aws/frontend/nextjs), + [Nuxt](/aws/frontend/nuxt), + [SvelteKit](/aws/frontend/sveltekit), + [Waku](/aws/frontend/waku), + [Octane](/aws/frontend/octane). +- [Full-stack RPC + Drizzle](/aws/frontend/full-stack-tanstack-rpc-drizzle) — + a TanStack Start UI driving an Effect RPC Lambda over Aurora DSQL. +- [`StaticSite` reference](/providers/aws/website/staticsite) and + [`Router` reference](/providers/aws/website/router) — every prop + and attribute. diff --git a/.agents/skills/alchemy/references/aws/local-development.md b/.agents/skills/alchemy/references/aws/local-development.md new file mode 100644 index 00000000..84fdbfd2 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/local-development.md @@ -0,0 +1,255 @@ + + +# Local development + +> alchemy dev runs your AWS stack on your machine — Lambda in Docker containers, local S3/DynamoDB/SQS, event sources firing, hot reload — with no AWS account or credentials required. + +`alchemy dev` runs your AWS stack entirely on your machine — no +AWS account, no credentials, no config. Lambda functions execute +in Docker containers behind working Function URLs, websites run +their frameworks' own dev servers with native HMR, ECS tasks run +as real containers, and the surrounding services (S3, DynamoDB, +SQS, SNS, EventBridge, and many more) are emulated locally. Code +changes hot reload in ~100–500ms. + +```sh +alchemy dev +# no AWS credentials — using the local emulator +``` + +```text +✓ Files (AWS.S3.Bucket) created (local) +✓ Users (AWS.DynamoDB.Table) created (local) +✓ Jobs (AWS.SQS.Queue) created (local) +✓ Api (AWS.Lambda.Function) created (local → docker) + • http://b71ff5c0….lambda-url.us-east-1.localhost:4566/ + +Watching for changes ... +``` + +## How it works + +Local AWS emulation is powered by +[our fork](https://github.com/alchemy-run/floci) of +[floci](https://floci.io), an MIT-licensed LocalStack-style +emulator. The emulator starts automatically in Docker — one +container (`alchemy-floci`) shared across projects and sessions — +and your stack deploys into it in seconds. Resource identifiers +carry the `dev:` marker and local endpoints +(`localhost:4566`), which doubles as proof that no cloud call +ran. + +We hold the emulator to the same bar as the real cloud: alchemy's +live AWS test suites — the control-plane lifecycle tests and the +data-plane binding tests that normally run against real AWS — +also run against the emulator, and every fidelity gap they +surface becomes a fix in the fork. + +## Lambda runs in containers + +Each Lambda function executes in a local runtime container behind +a working Function URL: + +```typescript +export default class Api extends AWS.Lambda.Function()( + "Api", + { main: import.meta.url, functionUrl: true }, + Effect.gen(function* () { + const files = yield* AWS.S3.Bucket("Files"); + const jobs = yield* AWS.SQS.Queue("Jobs"); + + const putObject = yield* AWS.S3.PutObject(files); + const sendMessage = yield* AWS.SQS.SendMessage(jobs); + + return { + fetch: Effect.gen(function* () { + yield* putObject({ Key: "hello.txt", Body: "hello" }); + yield* sendMessage({ MessageBody: "process hello.txt" }); + return yield* HttpServerResponse.text("ok"); + }).pipe(Effect.orDie), + }; + }).pipe( + Effect.provide([AWS.S3.PutObjectHttp, AWS.SQS.SendMessageHttp]), + ), +) {} +``` + +In dev, the function serves at a working local Function URL +(`http://….lambda-url.us-east-1.localhost:4566/`), `putObject` +lands in the local bucket, and `sendMessage` lands in the local +queue. The SDK honors the standard `AWS_ENDPOINT_URL` environment +override that the emulator injects into every dev container. + +Save the file and the running function serves the new code in +**~100–500ms** — no redeploy, no restart. The dev session watches +your code's module graph, rebuilds, and swaps the code under the +live Function URL. + +## Websites run their own dev servers + +Every `AWS.Website.*` resource — Vite, Next.js, Astro, Nuxt, +SvelteKit, Waku, Octane, StaticSite — runs its framework's own +dev server under `alchemy dev`: native HMR, no cloud resources. +The site's `url` is the local dev server's address: + +```typescript +const site = yield* AWS.Website.Vite("Web"); +// dev: site.url = http://localhost:5173 — Vite's own dev server, HMR included +// deploy: site.url = the CloudFront distribution +``` + +The same stack deploys unchanged: `alchemy deploy` runs +`vite build` and serves the output from S3 through CloudFront. For +SSR frameworks, `server.environment` is delivered to the dev +server's process env, so config reads work the same locally and +deployed. See [AWS frontends](/aws/frontend/websites). + +## ECS tasks run as containers + +ECS tasks and services run as real Docker containers, built and +pushed through a local OCI registry: + +```typescript +// bundled Effect program — built into an image, pushed through +// a local registry, running as a real container seconds later +export default class Web extends AWS.ECS.Task()( + "Web", + { main: import.meta.url, port: 8080 }, + Effect.gen(function* () { + return { + fetch: Effect.gen(function* () { + return HttpServerResponse.text("hello from a local container"); + }), + }; + }), +) {} +``` + +Bring-your-own-Dockerfile containers work too: +`AWS.ECS.Service("Api", { context: "./api", port: 3000 })` builds +your image and runs it the same way. + +The whole live pipeline runs unchanged: image build, `docker +push`, task definitions, `RunTask`, and services that converge +exactly like AWS. Hot reload swaps a running container in ~6 +seconds — bundled tasks watch the deploy's exact module graph, +Dockerfile tasks watch the build context, and services roll their +tasks onto the new image revision. Emulated ALB listeners route +to the local containers; each task is also reachable directly at +its literal host port (`localhost:`). + +## Event sources fire locally + +The glue between services works end to end in the emulator: +SQS→Lambda event source mappings poll and deliver batches, S3 +notifications fire, EventBridge rules route, SNS subscriptions +deliver, DynamoDB Streams pump changes, and EventBridge Scheduler +schedules fire — so a `consumeQueueMessages` handler in a local +Lambda drains a local queue exactly like it would in the cloud. + +## What's emulated + +Close to 40 services and over 200 resource types run against the +local emulator in dev: + +| Category | Services | +| ------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Compute | Lambda (functions, layers, versions, aliases, event source mappings, permissions), ECS, ECR, Batch, Auto Scaling, Application Auto Scaling | +| APIs & delivery | API Gateway (REST), API Gateway v2 (HTTP & WebSocket), AppSync, CloudFront (Distributions, Functions, KeyValueStore), WAFv2, ACM | +| Storage & databases | S3, DynamoDB, RDS, Glue, Athena, S3 Vectors | +| Messaging & events | SQS, SNS, EventBridge, Kinesis, Firehose, Pipes, Scheduler, Step Functions | +| Networking & DNS | EC2 (VPCs, subnets, security groups, instances, …), ELBv2, Route 53, Cloud Map | +| Identity & security | IAM, Cognito, KMS, Secrets Manager | +| Config & ops | SSM Parameter Store, AppConfig, CloudWatch Logs, SES | + +Resources without local emulation run against the real cloud in +your personal [stage](/environments/stages) — a stack that mixes +emulated and live-only resources just works. + +## Mix in the real cloud + +`Alchemy.remote()` opts any resource out of local emulation: + +```typescript +// everything else is local; this one hits real AWS +const secret = yield* AWS.SecretsManager.Secret("ProdSecret", { ... }) + .pipe(Alchemy.remote()); +``` + +If a remote resource needs credentials you don't have, +`alchemy dev` tells you before touching anything: + +```text +AWS credentials are required, but none are configured for profile 'default'. +These resources require AWS credentials: + - ProdSecret (runs against the real cloud via Alchemy.remote()) +Run `alchemy profile edit --profile default --add AWS` to configure credentials, or set CI=1 to use environment-variable credentials. +``` + +Switching a resource between local and live (or dev → deploy) +plans a **replacement** — dev state never silently becomes cloud +state. See +[Local development](/environments/local-development) for the +shared semantics. + +## Emulator management + +The emulator container outlives dev sessions — Docker is the +supervisor, so there's nothing to babysit. Overrides when you +want them: + +```sh +ALCHEMY_FLOCI_IMAGE=floci:dev alchemy dev # use your own emulator build +``` + +The default image is our patched release +(`ghcr.io/alchemy-run/floci`). + +To wipe everything inside the emulator without recreating the +container, point [`nuke`](/cli/nuke) at the local providers: + +```sh +bun alchemy unsafe nuke --local --include 'AWS.*' +``` + +`--local` enumerates and deletes through each provider's local +implementation, so it only ever sees emulated resources — no +cloud credentials are involved and nothing in a real AWS account +can be touched. + +## Test against the local stack + +The [test harness](/testing/test-harness) has the same dev mode — +`Test.make({ dev: true })` (available on both the Bun and Vitest +adapters, off by default) runs the suite's `deploy(Stack)` against +the local emulator instead of the real account, no separate +`alchemy dev` terminal needed: + +```typescript +const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ + providers: AWS.providers(), + dev: true, +}); +``` + +Lambda functions run in local containers, the emulated services +back them, and function URL outputs point at the local gateway — +so the same HTTP assertions run with zero cloud credentials. See +[Test harness → dev](/testing/test-harness#dev) for the full +semantics. + +## Where next + +- [Local development](/environments/local-development) — the + shared `alchemy dev` concepts: hot reload, `Alchemy.remote()`, + and dev vs deploy semantics. +- [AWS frontends](/aws/frontend/websites) — framework dev servers + under `alchemy dev`. +- [Stages](/environments/stages) — how live-in-dev resources stay + isolated per developer. +- [Testing](/testing) — run the same test suite against the local + emulator with `Test.make({ dev: true })`. +- [Local Providers](/infrastructure-as-code/local-provider) — + build the local implementation of a resource. diff --git a/.agents/skills/alchemy/references/aws/messaging/dynamodb-streams.md b/.agents/skills/alchemy/references/aws/messaging/dynamodb-streams.md new file mode 100644 index 00000000..4bba224d --- /dev/null +++ b/.agents/skills/alchemy/references/aws/messaging/dynamodb-streams.md @@ -0,0 +1,194 @@ + + +# Process DynamoDB Streams + +> Enable a DynamoDB Stream on your table and consume change records as a typed Effect Stream from the same Lambda. + +The DynamoDB table from the previous part stores records, but +writes happen in the dark — there's no way for downstream systems +to react. **DynamoDB Streams** fix that: every change to the +table emits a change record (insert / modify / remove) you can +stream into a Lambda. In Alchemy this is one helper call. + +## Subscribe to the stream + +Start with the smallest possible subscription: log just the +event name (`INSERT` / `MODIFY` / `REMOVE`) for each change. +Add it alongside the existing bindings: + +```diff lang="typescript" +// src/api.ts +import * as AWS from "alchemy/AWS"; +import * as DynamoDB from "alchemy/AWS/DynamoDB"; ++import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; ++import * as Stream from "effect/Stream"; +// ... + +const table = yield* DynamoDB.Table("Items", { /* ... */ }); +const putItem = yield* DynamoDB.PutItem(table); +const getItem = yield* DynamoDB.GetItem(table); + ++yield* DynamoDB.consumeTableChanges(table, (stream) => ++ stream.pipe( ++ Stream.runForEach((record) => ++ Console.log(`${record.eventName}: ${record.dynamodb.Keys?.id?.S}`), ++ ), ++ ), ++); +``` + +That single `consumeTableChanges(...)` call enables a stream on the table, +creates a Lambda `EventSourceMapping` that polls it, and adds +the IAM grants for `dynamodb:GetRecords`, `GetShardIterator`, +`DescribeStream`, and `ListStreams` — all from the call site. + +## Pick the view type + +By default the stream emits keys only. Most handlers want the +full row before and after the change — set `streamViewType` to +`NEW_AND_OLD_IMAGES`: + +```diff lang="typescript" +-yield* DynamoDB.consumeTableChanges(table, (stream) => ++yield* DynamoDB.consumeTableChanges(table, { ++ streamViewType: "NEW_AND_OLD_IMAGES", ++}, (stream) => + stream.pipe(/* ... */), +); +``` + +The other options (`KEYS_ONLY`, `NEW_IMAGE`, `OLD_IMAGE`) trade +payload size for less detail — pick the smallest one your +handler actually needs. + +## Control where consumption starts + +`startingPosition: "LATEST"` is the default and ignores history; +the consumer only sees records that arrive after the +event-source mapping is enabled. Switch to `"TRIM_HORIZON"` to +replay everything currently in the stream's 24-hour retention +window: + +```diff lang="typescript" +yield* DynamoDB.consumeTableChanges(table, { + streamViewType: "NEW_AND_OLD_IMAGES", ++ startingPosition: "TRIM_HORIZON", ++ batchSize: 10, +}, (stream) => + stream.pipe(/* ... */), +); +``` + +`batchSize` caps the number of records the function sees per +invocation. Larger batches are more efficient; smaller batches +fail in smaller blast radii. + +## Type the row payload + +DynamoDB stream records use the same attribute-value shape as +`GetItem`/`PutItem`. Describe the row inline as a generic on +`consumeTableChanges` and TypeScript will help you destructure +`NewImage`/`OldImage`: + +```diff lang="typescript" +-yield* DynamoDB.consumeTableChanges(table, { ++yield* DynamoDB.consumeTableChanges<{ id: { S: string }; content: { S: string } }>(table, { + streamViewType: "NEW_AND_OLD_IMAGES", + startingPosition: "TRIM_HORIZON", + batchSize: 10, +}, (stream) => + stream.pipe( + Stream.runForEach((record) => +- Console.log(`${record.eventName}: ${record.dynamodb.Keys?.id?.S}`), ++ Console.log( ++ `${record.eventName}: ${record.dynamodb.Keys?.id?.S} -> ` + ++ `${record.dynamodb.NewImage?.content.S ?? "(deleted)"}`, ++ ), + ), + ), +); +``` + +## Provide the runtime layer + +The Lambda-specific implementation of `TableEventSource` is in +`Lambda.TableEventSource`. Add it to the merged layer: + +```diff lang="typescript" +}).pipe( + Effect.provide( + Layer.mergeAll( + AWS.Lambda.BucketEventSource, ++ AWS.Lambda.TableEventSource, + DynamoDB.GetItemHttp, + DynamoDB.PutItemHttp, + S3.PutObjectHttp, + S3.GetObjectHttp, + ), + ), +), +``` + +`TableEventSource` is the binding that mutates the table's stream +configuration during deploy and creates the event source mapping. +You don't have to think about either — they're both side effects +of `consumeTableChanges(...)`. + +## Deploy + +```sh +bun alchemy deploy +``` + +Alchemy enables the stream on the table (takes a few seconds for +the table to settle) then creates the event source mapping. Once +the mapping is `Enabled`, every `PutItem`/`UpdateItem`/`DeleteItem` +shows up in your function's CloudWatch logs. + +## Verify + +Reuse the `PUT /items/:id` route to publish changes, then tail +the function logs to confirm they arrive: + +```sh +bun alchemy logs Api --tail +``` + +In another terminal: + +```sh +curl -X PUT --data 'first' "$URL/items/one" +curl -X PUT --data 'second' "$URL/items/two" +curl -X PUT --data 'updated' "$URL/items/one" +``` + +You'll see three `MODIFY`/`INSERT` log lines stream past — one +per write. Stream records typically arrive within 1–2 seconds of +the originating change. + +## Why a Stream instead of a callback? + +The `consumeTableChanges(table, handler)` signature mirrors the S3 +events helper for a reason — once your event source is a typed `Stream`, +back-pressure, batching, retries, parallel side effects, and +in-order processing are all just composition: + +```typescript +stream.pipe( + Stream.groupedWithin(50, "5 seconds"), + Stream.mapEffect((batch) => writeToWarehouse(batch), { concurrency: 4 }), + Stream.runDrain, +); +``` + +The same operators work for DynamoDB Streams, S3 events, SQS +messages, and Kinesis records — the only thing that changes is +the element type. + +Next we'll add an [SQS Queue](/aws/messaging/sqs) so the Lambda +can fan changes (or anything else) out to a different consumer +process and pick them up downstream. diff --git a/.agents/skills/alchemy/references/aws/messaging/eventbridge.md b/.agents/skills/alchemy/references/aws/messaging/eventbridge.md new file mode 100644 index 00000000..6ded74f0 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/messaging/eventbridge.md @@ -0,0 +1,309 @@ + + +# EventBridge & Scheduler + +> Route application and AWS events through EventBridge buses and rules, consume them in Lambda as typed streams, and run cron/rate schedules against Lambda, SQS, and ECS with EventBridge Scheduler. + +**EventBridge** is AWS's event router — producers publish events onto +a bus, rules match them by pattern, and targets receive them. +**EventBridge Scheduler** is its time-based sibling: fire an +invocation on a `rate(...)`, `cron(...)`, or one-time `at(...)` +expression. In Alchemy the surface is: + +- `EventBridge.EventBus` and `EventBridge.Rule` — the raw resources. +- `EventBridge.PutEvents` — a producer binding that grants + `events:PutEvents` on the bus you publish to. +- `EventBridge.consumeBusEvents` — subscribe a Lambda to a pattern as + a typed `Stream`, creating the rule and invoke permission for you. +- `EventBridge.events(bus, pattern).toLambda/.toQueue/.toEcsTask` — + route matching events to a target with no handler code. +- `Scheduler.every/cron/at` — chainable schedule builders that + synthesize the target's IAM role. +- `ECS.every` — run a Fargate task on a schedule. + +## Create an event bus + +An event bus is a one-line resource. Declare it in a shared module so +producers and consumers can reference the same handle: + +```typescript +// src/bus.ts +import * as AWS from "alchemy/AWS"; + +export const Events = AWS.EventBridge.EventBus("Events", {}); +``` + +No name required — Alchemy generates one from the app, stage, and +logical ID. The name `default` is reserved: to target the account's +**default bus** (where AWS service events land), simply omit the bus +argument in the APIs below. + +## Publish events from a Lambda + +`EventBridge.PutEvents(bus)` returns a callable Effect and quietly +attaches an `events:PutEvents` policy statement scoped to the bus ARN +(or the default bus, if you pass nothing): + +```typescript +// src/api.ts +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; +import { Events } from "./bus.ts"; + +export default class Api extends AWS.Lambda.Function()( + "Api", + { main: import.meta.url, functionUrl: true }, + Effect.gen(function* () { + const bus = yield* Events; + const putEvents = yield* AWS.EventBridge.PutEvents(bus); + + return { + fetch: Effect.gen(function* () { + yield* putEvents({ + Entries: [ + { + Source: "app.orders", + DetailType: "OrderPlaced", + Detail: JSON.stringify({ orderId: "o-1" }), + }, + ], + }).pipe(Effect.orDie); + return HttpServerResponse.empty({ status: 202 }); + }), + }; + }).pipe(Effect.provide(AWS.EventBridge.PutEventsHttp)), +) {} +``` + +Entries carry a `Source`, a `DetailType`, and a JSON-encoded `Detail` +— the three fields rules match on. The `EventBusName` is filled in +from the bus you bound, so entries never repeat it. +`AWS.EventBridge.PutEventsHttp` is the runtime layer that implements +the binding over the EventBridge API. + +## Consume events in a Lambda + +`consumeBusEvents` is the consumer-side mirror of +`SQS.consumeQueueMessages`: pass the bus, an event pattern, and a +handler that receives matching events as a typed `Stream`: + +```typescript +// src/worker.ts +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; +import { Events } from "./bus.ts"; + +export default class Worker extends AWS.Lambda.Function()( + "OrdersWorker", + { main: import.meta.url }, + Effect.gen(function* () { + const bus = yield* Events; + + yield* AWS.EventBridge.consumeBusEvents( + bus, + { source: ["app.orders"], "detail-type": ["OrderPlaced"] }, + (events) => + Stream.runForEach(events, (event) => + Effect.log(`order placed: ${JSON.stringify(event.detail)}`), + ), + ); + + return {}; + }).pipe(Effect.provide(AWS.Lambda.EventSource)), +) {} +``` + +That single call does the infrastructure work at deploy time — an +EventBridge `Rule` with the pattern targeting this function, plus the +Lambda permission that lets `events.amazonaws.com` invoke it — and at +runtime filters incoming invocations against the same pattern before +feeding them into your stream. Omit the `bus` argument to subscribe to +the default bus, which is where AWS service events (S3, EC2, ECS +state changes, …) arrive. + +`AWS.Lambda.EventSource` is the Lambda runtime implementation of the +EventBridge event source — provide it at the bottom of the function +like any other event source layer. + +### Type the event detail + +Events are `EventRecord` with `Detail` defaulting to +`unknown`. Narrow the stream when you know the payload shape: + +```typescript +type OrderPlaced = { orderId: string }; + +yield* AWS.EventBridge.consumeBusEvents( + bus, + { source: ["app.orders"], "detail-type": ["OrderPlaced"] }, + (events) => + Stream.runForEach( + events as Stream.Stream>, + (event) => Effect.log(`order: ${event.detail.orderId}`), + ), +); +``` + +## Route events without a handler + +Not every consumer is code you own. `EventBridge.events(bus, pattern)` +builds a route — chain `.toLambda`, `.toQueue`, or `.toEcsTask` to +deliver matching events to a target resource, permissions included: + +```typescript +// alchemy.run.ts +yield* AWS.EventBridge.events(bus, { source: ["app.orders"] }) + .toQueue(auditQueue); +``` + +Each target wires its own access: `.toQueue` attaches a queue policy +allowing `events.amazonaws.com` to `sqs:SendMessage` (conditioned on +the rule's ARN), `.toLambda` adds the invoke permission, and +`.toEcsTask` creates an IAM role with `ecs:RunTask` and `iam:PassRole` +for the task's roles. Pass a string as the first argument to name the +backing rule deterministically instead of deriving it from the +pattern hash. + +## Rules directly + +The helpers above all bottom out in the `Rule` resource. Reach for it +directly when you need full control — input transformers, dead-letter +queues, retry policies, or up to five targets per rule: + +```typescript +const rule = yield* AWS.EventBridge.Rule("ObjectCreated", { + eventPattern: { + source: ["aws.s3"], + "detail-type": ["Object Created"], + }, + targets: [{ Id: "Jobs", Arn: queue.queueArn }], +}); +``` + +A rule needs an `eventPattern` or a `scheduleExpression` (rules also +accept `rate(...)`/`cron(...)` expressions, though Scheduler below is +the better tool for time-based work). Omitting `eventBusName` puts +the rule on the default bus. Unlike the `events(...)` helpers, a raw +`Rule` does **not** grant the target-side permissions — supply a +target `RoleArn` or resource policy yourself. + +## Scheduled jobs with Scheduler + +`AWS.Scheduler` exposes chainable builders. The simplest form invokes +a Lambda on a fixed rate: + +```typescript +// alchemy.run.ts +const api = yield* Api; + +yield* AWS.Scheduler.every("5 minutes").toLambda(api); +``` + +`every` accepts a plain-English duration (normalized to +`rate(5 minutes)`) or a full `rate(...)`/`cron(...)` expression +as-is. The builder synthesizes the piece Scheduler always demands — +an execution role assumable by `scheduler.amazonaws.com` with +`lambda:InvokeFunction` on exactly that function — then creates the +`Scheduler.Schedule` resource. + +Cron schedules take an options object with a timezone, and `.named` +pins the schedule's name: + +```typescript +yield* AWS.Scheduler + .cron("cron(0 3 * * ? *)", { timezone: "America/New_York" }) + .named("nightly-report") + .toLambda(api, { input: { job: "nightly-report" } }); +``` + +`input` is delivered as the invocation payload (objects are +JSON-stringified for you); `retryPolicy` and `deadLetterConfig` are +also accepted per target. + +One-time schedules use `at(date)`, and `.toQueue` sends a payload to +SQS instead of invoking a function: + +```typescript +yield* AWS.Scheduler + .at(new Date("2026-08-01T09:00:00Z")) + .toQueue(queue, { type: "launch.reminder" }); +``` + +Builder options cover the rest of the Scheduler feature set — +`group` (a `Scheduler.ScheduleGroup`), `startDate`/`endDate`, +`flexibleTimeWindow`, `state`, `kmsKeyArn`, and +`actionAfterCompletion` for one-time schedules. + +### The Schedule resource directly + +The builders materialize `Scheduler.Schedule` under the hood. Use it +directly when you bring your own role: + +```typescript +const schedule = yield* AWS.Scheduler.Schedule("Poll", { + scheduleExpression: "rate(1 hour)", + flexibleTimeWindow: { Mode: "OFF" }, + target: { + Arn: queue.queueArn, + RoleArn: role.roleArn, + }, +}); +``` + +The `RoleArn` must trust `scheduler.amazonaws.com`. Alchemy retries +the creation while the freshly-created IAM role propagates, so a role +declared in the same deploy just works. + +## ECS scheduled tasks + +For jobs too heavy for Lambda, `AWS.ECS.every` runs a Fargate task on +a schedule — cron-as-a-container: + +```typescript +yield* AWS.ECS.every("HourlyJob", "1 hour", { + cluster, + task: jobTask, + subnets: [privateSubnet1.subnetId, privateSubnet2.subnetId], + securityGroups: [jobSecurityGroup.groupId], +}); +``` + +`cluster` and `task` are the outputs of `AWS.ECS.Cluster` and +`AWS.ECS.Task` (see [ECS](/aws/compute/ecs) for standing those up). Under the +hood this is `Scheduler.every(...).named("HourlyJob").toEcsTask(...)`: +a schedule plus an execution role with `ecs:RunTask` on the task +definition and `iam:PassRole` for its task and execution roles. Plain +durations normalize to `rate(...)`; full `cron(...)` expressions pass +through unchanged: + +```typescript +yield* AWS.ECS.every("NightlyJob", "cron(0 3 * * ? *)", { + cluster, + task: nightlyTask, + subnets: [privateSubnet1.subnetId, privateSubnet2.subnetId], + securityGroups: [jobSecurityGroup.groupId], + taskCount: 3, + input: JSON.stringify({ source: "scheduler" }), +}); +``` + +Tasks launch as Fargate with no public IP by default +(`assignPublicIp: true` to change that), `taskCount` copies per tick, +and an optional static `input` forwarded to the task. + +## Where next + +- [Lambda](/aws/compute/lambda) — the runtime both the consumer and scheduled + targets above deploy to. +- [SNS](/aws/messaging/sns) — pub/sub fan-out when you want subscriptions + rather than pattern-matching rules. +- [ECS](/aws/compute/ecs) — the cluster, task, and networking that + `ECS.every` schedules onto. +- [`EventBus`](/providers/aws/eventbridge/eventbus), + [`Rule`](/providers/aws/eventbridge/rule), and + [`Schedule`](/providers/aws/scheduler/schedule) references — every + prop and attribute. diff --git a/.agents/skills/alchemy/references/aws/messaging/kinesis.md b/.agents/skills/alchemy/references/aws/messaging/kinesis.md new file mode 100644 index 00000000..5419c948 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/messaging/kinesis.md @@ -0,0 +1,300 @@ + + +# Kinesis + +> Add a Kinesis Data Stream, publish records from one Lambda, and consume them in order from another — wired through the same Stream-shaped event source. + +**Kinesis Data Streams** are the tool for **ordered**, sharded, +high-throughput data: delivery in order within a partition, +replay from arbitrary points in time, and multiple independent +consumers reading the same records. In Alchemy it's the `Stream` +resource plus producer bindings (`PutRecord`, `PutRecords`, and +the sink-shaped `StreamSink`) and a consumer side: +`Kinesis.consumeStreamRecords` subscribes a Lambda to the stream +as a typed Effect `Stream`, creating the event source mapping +and granting the read permissions for you. Reach for +[SQS](/aws/messaging/sqs) instead when the work is unordered. + +The walkthrough below builds on the [Lambda](/aws/compute/lambda), +[DynamoDB](/aws/data/dynamodb), and [SQS](/aws/messaging/sqs) pages: it adds a +Kinesis stream, publishes to it from the producer Lambda, and +consumes it from a new worker. + +## Add the stream + +Lift the Kinesis stream into a shared module the same way you +did with the SQS queue, so both producer and consumer can + +```typescript +// src/stream.ts +import * as Kinesis from "alchemy/AWS/Kinesis"; + +export const Events = Kinesis.Stream("Events", { + streamMode: "ON_DEMAND", +}); +``` + +`"ON_DEMAND"` lets AWS auto-scale the shards based on load — +fine for the example. Switch to `"PROVISIONED"` with an explicit +`shardCount` when you know your throughput. + +## Bind PutRecord on the producer + +```diff lang="typescript" +// src/api.ts +import * as DynamoDB from "alchemy/AWS/DynamoDB"; ++import * as Kinesis from "alchemy/AWS/Kinesis"; +import * as S3 from "alchemy/AWS/S3"; +import * as SQS from "alchemy/AWS/SQS"; +import { Jobs } from "./queue.ts"; ++import { Events } from "./stream.ts"; +// ... + +const queue = yield* Jobs; ++const events = yield* Events; +const sendMessage = yield* SQS.SendMessage(queue); ++const putRecord = yield* Kinesis.PutRecord(events); +``` + +The shape mirrors every other binding you've seen: `PutRecord(stream)` +returns a callable Effect, plus an IAM policy with +`kinesis:PutRecord` scoped to this stream's ARN. + +## Publish a record + +Use `putRecord` inside the `PUT /items/:id` route — alongside +the SQS publish — to send an ordered audit record for each +write: + +```diff lang="typescript" +yield* sendMessage({ + MessageBody: JSON.stringify({ type: "job.created", id, content }), +}); ++yield* putRecord({ ++ PartitionKey: id, ++ Data: new TextEncoder().encode( ++ JSON.stringify({ type: "job.created", id, content, at: Date.now() }), ++ ), ++}); +``` + +`PartitionKey` controls which shard a record lands in. All +records with the same partition key are delivered in order to +the same consumer instance — the entity id is exactly what you +want here. + +`Data` is an arbitrary `Uint8Array`, not a string. JSON-encoded +UTF-8 bytes are the most common payload but you can ship Avro, +protobuf, or raw blobs without changing the binding. + +## Provide the runtime layer + +```diff lang="typescript" +}).pipe( + Effect.provide( + Layer.mergeAll( + // ... existing layers ... ++ Kinesis.PutRecordHttp, + SQS.SendMessageHttp, + ), + ), +), +``` + +## Stand up a consumer Lambda + +Create `src/audit.ts` with a bare consumer shell — no event +source yet, just the empty class importing the shared stream +handle: + +```typescript +// src/audit.ts +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; +import { Events } from "./stream.ts"; + +export default class Audit extends AWS.Lambda.Function()( + "Audit", + { main: import.meta.url }, + Effect.gen(function* () { + const events = yield* Events; + return {}; + }), +) {} +``` + +## Subscribe to records + +`Kinesis.consumeStreamRecords(stream, ...)` mirrors the +[SQS `consumeQueueMessages(...)`](/aws/messaging/sqs) shape. +Add the smallest possible subscription — log each record's +partition key: + +```diff lang="typescript" +// src/audit.ts +import * as AWS from "alchemy/AWS"; ++import * as Kinesis from "alchemy/AWS/Kinesis"; ++import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; ++import * as Stream from "effect/Stream"; +import { Events } from "./stream.ts"; + +export default class Audit extends AWS.Lambda.Function()( + "Audit", + { main: import.meta.url }, + Effect.gen(function* () { + const events = yield* Events; ++ ++ yield* Kinesis.consumeStreamRecords(events, { ++ startingPosition: "LATEST", ++ batchSize: 100, ++ }, (stream) => ++ stream.pipe( ++ Stream.runForEach((record) => ++ Console.log(`[${record.kinesis.partitionKey}]`), ++ ), ++ ), ++ ); + + return {}; + }), +) {} +``` + +`batchSize: 100` lets Lambda gather up to 100 records per +invocation; the whole batch is delivered as one stream emission +sequence, then the stream completes and Lambda checkpoints. +`startingPosition: "LATEST"` skips history — switch to +`"TRIM_HORIZON"` to replay the 24-hour retention window. + +## Decode the binary payload + +Kinesis record data arrives base64-encoded inside the Lambda +envelope (an artifact of the event shape, not the stream +itself). Decode it back to bytes and on to a string, and run +the work as an Effect per record: + +```diff lang="typescript" +yield* Kinesis.consumeStreamRecords(events, { + startingPosition: "LATEST", + batchSize: 100, +}, (stream) => + stream.pipe( +- Stream.runForEach((record) => +- Console.log(`[${record.kinesis.partitionKey}]`), +- ), ++ Stream.mapEffect((record) => ++ Effect.gen(function* () { ++ const text = new TextDecoder().decode( ++ Buffer.from(record.kinesis.data, "base64"), ++ ); ++ yield* Console.log(`[${record.kinesis.partitionKey}] ${text}`); ++ }), ++ ), ++ Stream.runDrain, + ), +); +``` + +## Provide the runtime layer + +`Lambda.StreamEventSource` is the binding that enables the event +source mapping and grants `kinesis:GetRecords`, +`GetShardIterator`, `DescribeStream`, and `ListShards` on the +stream ARN. Provide it at the bottom of the function: + +```diff lang="typescript" + return {}; +- }), ++ }).pipe(Effect.provide(AWS.Lambda.StreamEventSource)), +) {} +``` + +## Wire the consumer into the Stack + +```diff lang="typescript" +// alchemy.run.ts +import Api from "./src/api.ts"; ++import Audit from "./src/audit.ts"; +import Worker from "./src/worker.ts"; + +Effect.gen(function* () { + const api = yield* Api; + yield* Worker; ++ yield* Audit; + return { url: api.functionUrl }; +}), +``` + +## Deploy + +```sh +bun alchemy deploy +``` + +Three functions now coexist: `Api` produces records, `Worker` +consumes the SQS queue, and `Audit` consumes the Kinesis stream. +Each one gets a least-privilege policy that names exactly the +resources its capability and `consume*` calls touched — no +`"*"`, no IAM JSON to maintain. + +## Verify + +```sh +curl -X PUT --data 'audit me' "$URL/items/k1" +bun alchemy logs Audit --tail +``` + +You'll see a line like: + +``` +[k1] {"type":"job.created","id":"k1","content":"audit me","at":1718000000000} +``` + +Subsequent writes against the same `id` (`k1`) all land in the +same shard and arrive in order at the same `Audit` invocation +context — that's the ordering guarantee Kinesis provides per +partition key. + +## Bonus: in-order side effects + +Because each batch is delivered as a `Stream`, **per-shard ordering +is preserved end-to-end**. If you need strict ordering for side +effects, drop the concurrency to 1 and let the stream do the work: + +```typescript +stream.pipe( + Stream.mapEffect( + (record) => persistInOrder(record).pipe(Effect.orDie), + { concurrency: 1 }, + ), + Stream.runDrain, +); +``` + +Or fan out per-partition with `Stream.groupByKey(record => record.kinesis.partitionKey)` +when ordering only matters within a key. + +## What you've built + +If you followed the whole section, you've gone from a single +Lambda to a small event-driven system: a public HTTP API backed +by S3 blobs and a DynamoDB table, table change events on a +stream, an SQS work queue, and a high-throughput Kinesis +pipeline — all wired through the same binding pattern, with IAM +policies that match exactly the operations you call. The same +`Stream`-shaped consumer surface drives every event source, so +adding more is a matter of declaring the resource and pointing +the matching `consume*` helper at it. + +## Where next + +- [Process DynamoDB Streams](/aws/messaging/dynamodb-streams) — + change data capture with the same consumer surface. +- [React to S3 events](/aws/messaging/s3-events) — bucket + notifications as an Effect `Stream`. +- [`Stream` reference](/providers/aws/kinesis/stream) — + provisioned vs on-demand mode, retention, and every other + prop. diff --git a/.agents/skills/alchemy/references/aws/messaging/s3-events.md b/.agents/skills/alchemy/references/aws/messaging/s3-events.md new file mode 100644 index 00000000..e91d94ff --- /dev/null +++ b/.agents/skills/alchemy/references/aws/messaging/s3-events.md @@ -0,0 +1,201 @@ + + +# React to S3 Events + +> Subscribe a Lambda Function to S3 bucket notifications, process them as an Effect Stream, and let Alchemy wire up the IAM and event-source plumbing. + +Reading and writing S3 objects on demand is one half of the story. +The other half is **reacting** to objects as they arrive — for +indexing, virus scanning, thumbnail generation, you name it. In +this part you'll subscribe the same Lambda from the previous part +to bucket notifications and process them as an Effect `Stream`. + +## Subscribe to bucket notifications + +Bucket notifications live behind an Alchemy helper: +`S3.consumeBucketEvents(bucket, process)`. Add the smallest +possible subscription — log every new object's key — alongside +the existing bindings: + +```diff lang="typescript" +// src/api.ts +import * as AWS from "alchemy/AWS"; +import * as S3 from "alchemy/AWS/S3"; ++import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; ++import * as Stream from "effect/Stream"; +// ... + +Effect.gen(function* () { + const bucket = yield* S3.Bucket("Blobs"); + const putObject = yield* S3.PutObject(bucket); + const getObject = yield* S3.GetObject(bucket); + ++ yield* S3.consumeBucketEvents(bucket, (stream) => ++ stream.pipe( ++ Stream.runForEach((event) => ++ Console.log(`new object: ${event.key}`), ++ ), ++ ), ++ ); + + return { + fetch: /* ... unchanged ... */, + }; +}); +``` + +That single `yield*` does three things in one shot: it +configures the bucket's notification settings to push events +into the function, adds the `lambda:InvokeFunction` permission +S3 needs to call you, and registers the stream handler that +runs every time an event batch lands. + +## Filter the event types + +Without a filter you get every notification S3 emits — object +created, deleted, restored, replicated, the lot. Constrain the +subscription to a specific subset with `events`: + +```diff lang="typescript" +-yield* S3.consumeBucketEvents(bucket, (stream) => ++yield* S3.consumeBucketEvents( ++ bucket, ++ { events: ["s3:ObjectCreated:*"] }, ++ (stream) => + stream.pipe( + Stream.runForEach((event) => + Console.log(`new object: ${event.key}`), + ), + ), +); +``` + +The `events` filter is type-checked against the AWS S3 event +catalog, so typos surface at the call site rather than at deploy +time. + +## What the stream actually carries + +`stream` is a `Stream.Stream` — a normalized +shape with `type`, `bucket`, `key`, `size`, and `eTag`. Each +Lambda invocation delivers one batch as one stream emission +sequence, then completes; the next invocation starts a fresh +stream. Treating events as a `Stream` means the usual operators +(`mapEffect`, `groupedWithin`, `mapAccum`) work without thinking +about the Lambda payload envelope. + +## Provide the runtime layer + +The runtime side of the subscription lives in +`Lambda.BucketEventSource`. It's the layer that turns the abstract +`BucketEventSource` capability into a concrete Lambda event-source +mapping plus IAM grant. Add it alongside the S3 bindings: + +```diff lang="typescript" +}).pipe( + Effect.provide( +- Layer.mergeAll(S3.PutObjectHttp, S3.GetObjectHttp), ++ Layer.mergeAll( ++ AWS.Lambda.BucketEventSource, ++ S3.PutObjectHttp, ++ S3.GetObjectHttp, ++ ), + ), +), +``` + +`BucketEventSource` and the `PutObject`/`GetObject` lives are all +peers here — none of them require each other — so a flat +`Layer.mergeAll(...)` is enough. + +## Run an Effect per event + +Logging is a good smoke test, but a real handler usually wants +to do something async per event. Swap `runForEach` for +`mapEffect` so each event runs through a generator block: + +```diff lang="typescript" +yield* S3.consumeBucketEvents( + bucket, + { events: ["s3:ObjectCreated:*"] }, + (stream) => + stream.pipe( +- Stream.runForEach((event) => +- Console.log(`new object: ${event.key}`), +- ), ++ Stream.mapEffect((event) => ++ Console.log(`new object: ${event.key}`), ++ ), ++ Stream.runDrain, ++ ), +); +``` + +`Stream.runDrain` consumes the stream without collecting — we +only care about the side effects, not the return values. + +## Read the object back + +`getObject` is already bound to the bucket, so the per-event +Effect can fetch the new object's contents inline: + +```diff lang="typescript" +stream.pipe( + Stream.mapEffect((event) => +- Console.log(`new object: ${event.key}`), ++ Effect.gen(function* () { ++ const result = yield* getObject({ Key: event.key }); ++ const body = yield* result.Body!.pipe( ++ Stream.decodeText, ++ Stream.mkString, ++ ); ++ yield* Console.log(`${event.key} -> ${body}`); ++ }).pipe(Effect.orDie), + ), + Stream.runDrain, +), +``` + +`Stream.mapEffect` is back-pressured by default — events flow +through one at a time. Pass a `concurrency` option (we'll see it +in later parts) when you want fan-out. + +## Deploy and verify + +```sh +bun alchemy deploy +``` + +The CLI applies the new bucket notification configuration and +redeploys the function with the broader IAM policy. Then trigger +the subscription end-to-end by `PUT`ing an object via the HTTP +route from the previous part: + +```typescript +// test/integ.test.ts +test( + "PUT triggers notification handler", + Effect.gen(function* () { + const { url } = yield* stack; + yield* HttpClient.put(`${url}/notify-me.txt`, { + body: HttpBody.text("payload"), + }); + // ...optionally tail CloudWatch logs to assert the message landed + }), +); +``` + +You'll see the `notify-me.txt -> payload` line in the function's +log group within a couple of seconds. + +S3 events typically arrive in batches of up to 10 records — the +same `Stream` is fed each batch and completes between invocations, +so you get the same composition story whether the batch is one +event or a hundred. + +Next we'll trade blob storage for structured records and +[add a DynamoDB table](/aws/data/dynamodb). diff --git a/.agents/skills/alchemy/references/aws/messaging/sns.md b/.agents/skills/alchemy/references/aws/messaging/sns.md new file mode 100644 index 00000000..b460d374 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/messaging/sns.md @@ -0,0 +1,310 @@ + + +# SNS + +> Create an SNS Topic, publish to it from a Lambda with the Publish binding, fan messages out to SQS queues, and consume notifications as a typed Stream. + +**SNS** is AWS's pub/sub layer: publishers send a message to a +**topic** once, and SNS pushes a copy to every subscriber — +Lambda functions, SQS queues, HTTPS endpoints, email. In Alchemy +it's the `Topic` and `Subscription` resources, producer bindings +(`Publish`, `PublishBatch`, and the `Stream`-sink-shaped +`TopicSink`), and a consumer side: +`SNS.consumeTopicNotifications` subscribes a Lambda to the topic +as a typed `Stream` of notifications, creating the subscription +and invoke permission for you. + +## Create a topic + +```typescript +// src/topic.ts +import * as SNS from "alchemy/AWS/SNS"; + +export const Orders = SNS.Topic("Orders"); +``` + +No name required — Alchemy generates one from the app, stage, +and logical ID. Raw SNS topic attributes (delivery policies, +KMS keys, display names) go through the `attributes` prop: + +```typescript +export const Orders = SNS.Topic("Orders", { + attributes: { + DisplayName: "Order events", + }, +}); +``` + +FIFO topics are a flag: + +```typescript +export const OrdersFifo = SNS.Topic("OrdersFifo", { + fifo: true, + attributes: { + ContentBasedDeduplication: "true", + }, +}); +``` + +Changing `fifo` or the topic name **replaces** the topic — +everything else updates in place. + +## Publish from a Lambda + +Bind `SNS.Publish(topic)` in the function's Construction phase and call +it from runtime handlers. The binding quietly attaches +`sns:Publish` scoped to the topic ARN to the function's +execution role: + +```typescript +// src/api.ts +import * as AWS from "alchemy/AWS"; +import * as SNS from "alchemy/AWS/SNS"; +import * as Effect from "effect/Effect"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; +import { Orders } from "./topic.ts"; + +export default class Api extends AWS.Lambda.Function()( + "Api", + { main: import.meta.url, functionUrl: true }, + Effect.gen(function* () { + const topic = yield* Orders; + const publish = yield* SNS.Publish(topic); + + return { + fetch: Effect.gen(function* () { + const response = yield* publish({ + Message: JSON.stringify({ orderId: "o_123" }), + Subject: "OrderCreated", + }); + return yield* HttpServerResponse.json({ + id: response.MessageId, + }); + }).pipe(Effect.orDie), + }; + }).pipe(Effect.provide(SNS.PublishHttp)), +) {} +``` + +`SNS.PublishHttp` is the runtime layer that backs the binding +with the SNS HTTP API — provide it on the function like any +other binding layer. + +## Publish in batches + +`SNS.PublishBatch(topic)` ships up to 10 messages per API call +(runtime layer: `SNS.PublishBatchHttp`): + +```typescript +const publishBatch = yield* SNS.PublishBatch(topic); + +// runtime +const response = yield* publishBatch({ + PublishBatchRequestEntries: messages.map((message, index) => ({ + Id: `${index}`, + Message: message, + })), +}); +// response.Successful / response.Failed +``` + +## Forward a stream with `TopicSink` + +If you have a `Stream` of payloads — DynamoDB change +records, SQS messages, anything — `SNS.TopicSink(topic)` is the +sink-shaped producer: it batches items and publishes each chunk +through `PublishBatch`: + +```typescript +const sink = yield* SNS.TopicSink(topic); + +// runtime +yield* Stream.fromIterable(payloads).pipe(Stream.run(sink)); +``` + +`TopicSinkHttp` is implemented on top of the `PublishBatch` +binding, so provide it in two tiers with `Layer.provideMerge` — +the sink on top, the batch layer beneath: + +```typescript +Effect.provide( + Layer.provideMerge(SNS.TopicSinkHttp, SNS.PublishBatchHttp), +) +``` + +## Consume notifications on a Lambda + +`SNS.consumeTopicNotifications(topic, ...)` is the consumer-side +mirror of `SQS.consumeQueueMessages`: a typed `Stream` of SNS +notifications you compose with any operator. Behind the scenes +it creates the SNS subscription (protocol `lambda`, endpoint = +this function) and the `lambda:InvokeFunction` permission for +`sns.amazonaws.com`: + +```typescript +// src/worker.ts +import * as AWS from "alchemy/AWS"; +import * as SNS from "alchemy/AWS/SNS"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; +import { Orders } from "./topic.ts"; + +export default class Worker extends AWS.Lambda.Function()( + "OrdersWorker", + { main: import.meta.url }, + Effect.gen(function* () { + const topic = yield* Orders; + + yield* SNS.consumeTopicNotifications(topic, (stream) => + stream.pipe( + Stream.runForEach((notification) => + Console.log( + `${notification.Subject}: ${notification.Message}`, + ), + ), + ), + ); + + return {}; + }).pipe(Effect.provide(AWS.Lambda.TopicEventSource)), +) {} +``` + +Each notification carries the SNS message shape — `Message`, +`Subject`, `MessageId`, `TopicArn`, and friends. The +consumer-side machinery ships in `AWS.Lambda.TopicEventSource`; +provide it on the function. + +## Filter what the consumer sees + +Pass subscription attributes as an optional middle argument — +`FilterPolicy` makes SNS drop non-matching messages before they +ever invoke your function: + +```typescript +yield* SNS.consumeTopicNotifications( + topic, + { + attributes: { + FilterPolicy: JSON.stringify({ type: ["order.created"] }), + }, + }, + (stream) => + stream.pipe( + Stream.runForEach((notification) => + Console.log(notification.Message), + ), + ), +); +``` + +## Fan out to SQS queues + +The classic SNS pattern is topic-to-queues fan-out: one publish, +one copy per queue, each queue drained by its own consumer at +its own pace. Subscriptions are their own resource, so wiring a +queue to a topic is one declaration: + +```typescript +// alchemy.run.ts (inside the Stack's Effect.gen) +import * as SNS from "alchemy/AWS/SNS"; +import * as SQS from "alchemy/AWS/SQS"; + +const topic = yield* Orders; + +const emails = yield* SQS.Queue("EmailJobs"); +const analytics = yield* SQS.Queue("AnalyticsJobs"); + +yield* SNS.Subscription("EmailSubscription", { + topicArn: topic.topicArn, + protocol: "sqs", + endpoint: emails.queueArn, +}); + +yield* SNS.Subscription("AnalyticsSubscription", { + topicArn: topic.topicArn, + protocol: "sqs", + endpoint: analytics.queueArn, +}); +``` + +One AWS-side requirement: the queue's access policy must let SNS +deliver to it. Grant it with the queue's `policy` prop, scoped +to the topic: + +```typescript +const emails = yield* SQS.Queue("EmailJobs", { + policy: { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { Service: "sns.amazonaws.com" }, + Action: "sqs:SendMessage", + Resource: "*", + Condition: { + ArnEquals: { "aws:SourceArn": topic.topicArn }, + }, + }, + ], + }, +}); +``` + +By default SNS wraps each delivery in a JSON envelope +(`Message`, `Subject`, `TopicArn`, ...). Set +`RawMessageDelivery` on the subscription to receive the bare +message body instead: + +```typescript +yield* SNS.Subscription("AnalyticsSubscription", { + topicArn: topic.topicArn, + protocol: "sqs", + endpoint: analytics.queueArn, + attributes: { + RawMessageDelivery: "true", + }, +}); +``` + +Changing a subscription's `protocol`, `topicArn`, or `endpoint` +**replaces** the subscription; `attributes` update in place. +From here each queue is a normal SQS consumer — +[`SQS.consumeQueueMessages`](/aws/messaging/sqs) picks the messages up on +whatever Lambda you point at it. + +## SNS vs EventBridge vs SQS + +All three move messages; they sit at different points: + +- **[SQS](/aws/messaging/sqs)** is one queue, one consumer group. + Point-to-point work distribution: producers enqueue, a pool of + workers drains. No fan-out — a message is consumed once. +- **SNS** is broadcast. Every subscriber gets its own copy, with + per-subscription filter policies. Reach for it when several + independent systems must react to the same event, or when you + want push delivery (Lambda, HTTPS, email) rather than polling. + Pair it with SQS (fan-out above) when each subscriber also + needs buffering and retries. +- **[EventBridge](/aws/messaging/eventbridge)** is a routed event bus: + rules match on the event *content* and route to targets, with + first-class support for AWS service events, schedules, and + third-party sources. Choose it for event-driven architectures + with many event types on one bus; choose SNS when you have one + well-known channel and want the simplest possible + publish/subscribe with high fan-out. + +## Where next + +- [SQS](/aws/messaging/sqs) — the queue side of the fan-out pattern: + consumers, batching, dead-letter queues. +- [EventBridge](/aws/messaging/eventbridge) — content-based routing when + one topic per channel stops scaling. +- [Lambda](/aws/compute/lambda) — the runtime both the publisher and + consumer above are built on. +- [`Topic` reference](/providers/aws/sns/topic) and + [`Subscription` reference](/providers/aws/sns/subscription) — + every prop and attribute. diff --git a/.agents/skills/alchemy/references/aws/messaging/sqs.md b/.agents/skills/alchemy/references/aws/messaging/sqs.md new file mode 100644 index 00000000..4f812a59 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/messaging/sqs.md @@ -0,0 +1,378 @@ + + +# SQS + +> Add an SQS Queue, publish messages from your Lambda, and consume them from a second consumer Lambda — all wired through Alchemy bindings. + +**SQS** is AWS's general-purpose message queue — anything you can +serialize, dropped into a mailbox and processed asynchronously +somewhere else. In Alchemy it's the `Queue` resource plus +producer bindings (`SendMessage`, `SendMessageBatch`, and the +`Stream`-sink-shaped `QueueSink`) and a consumer side: +`SQS.consumeQueueMessages` subscribes a Lambda to the queue as a +typed `Stream`, creating the event source mapping and +granting the receive/delete permissions for you. + +The walkthrough below builds on the [Lambda](/aws/compute/lambda) and +[DynamoDB](/aws/data/dynamodb) pages: it adds a **Queue**, sends +messages to it from the existing function, and stands up a +**second Lambda** that consumes them. + +## Add the queue + +```diff lang="typescript" +// src/api.ts +import * as AWS from "alchemy/AWS"; +import * as DynamoDB from "alchemy/AWS/DynamoDB"; ++import * as SQS from "alchemy/AWS/SQS"; +// ... + +Effect.gen(function* () { + const bucket = yield* S3.Bucket("Blobs"); + const table = yield* DynamoDB.Table("Items", { /* ... */ }); ++ const queue = yield* SQS.Queue("Jobs"); + + // ... existing bindings ... +}); +``` + +Calling `SQS.Queue("Jobs")` provisions a standard queue with the +defaults (4-day retention, 30-second visibility timeout, no +delivery delay). FIFO queues, dead-letter targets, and the rest +of the props can be supplied as a second argument when you need +them. + +## Bind SendMessage on the producer + +`SQS.SendMessage(queue)` returns a callable Effect — same +shape as `S3.PutObject` or `DynamoDB.PutItem` — and quietly +attaches `sqs:SendMessage` scoped to the queue ARN: + +```diff lang="typescript" +const putItem = yield* DynamoDB.PutItem(table); ++const sendMessage = yield* SQS.SendMessage(queue); +``` + +Let's hook it into the existing `PUT /items/:id` route so every +new record also drops a `job.created` message onto the queue: + +```diff lang="typescript" +if (request.method === "PUT") { + const content = yield* request.text; + yield* putItem({ + Item: { + id: { S: id }, + content: { S: content }, + }, + }); ++ yield* sendMessage({ ++ MessageBody: JSON.stringify({ ++ type: "job.created", ++ id, ++ content, ++ }), ++ }); + return HttpServerResponse.empty({ status: 204 }); +} +``` + +The producer also needs the runtime layer for `SendMessage` — +add it to the merged layer alongside the others: + +```diff lang="typescript" +}).pipe( + Effect.provide( + Layer.mergeAll( + AWS.Lambda.BucketEventSource, + AWS.Lambda.TableEventSource, + DynamoDB.GetItemHttp, + DynamoDB.PutItemHttp, + S3.PutObjectHttp, + S3.GetObjectHttp, ++ SQS.SendMessageHttp, + ), + ), +), +``` + +## Stand up a consumer Lambda + +Consumers live as their own Lambda Function. Create +`src/worker.ts` — bare shell first, no event source yet: + +```typescript +// src/worker.ts +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; + +export default class Worker extends AWS.Lambda.Function()( + "JobsWorker", + { main: import.meta.url }, + Effect.gen(function* () { + return {}; + }), +) {} +``` + +This consumer doesn't need a Function URL — its only entrypoint +is the SQS message poller AWS will configure for it. + +## Share the queue between two functions + +Both producer and consumer need to reference the same queue — +the cleanest way to do that is to lift the declaration out of +`api.ts` into a small shared module: + +```typescript +// src/queue.ts +import * as SQS from "alchemy/AWS/SQS"; + +export const Jobs = SQS.Queue("Jobs"); +``` + +Now both functions can import `Jobs`. Update the producer to use +the shared handle instead of declaring the queue inline: + +```diff lang="typescript" +// src/api.ts ++import { Jobs } from "./queue.ts"; +// ... +-const queue = yield* SQS.Queue("Jobs"); ++const queue = yield* Jobs; +``` + +## Resolve the queue in the consumer + +Yield the same `Jobs` handle in the consumer's constructor to get +a typed `Queue` resource: + +```diff lang="typescript" +// src/worker.ts +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; ++import { Jobs } from "./queue.ts"; + +export default class Worker extends AWS.Lambda.Function()( + "JobsWorker", + { main: import.meta.url }, + Effect.gen(function* () { ++ const queue = yield* Jobs; + return {}; + }), +) {} +``` + +## Subscribe to messages + +`SQS.consumeQueueMessages(queue, ...)` is the consumer-side mirror +of `consumeBucketEvents` / `stream`: a typed `Stream` you +can compose with any operator. Add the smallest possible +subscription — log each message body: + +```diff lang="typescript" +// src/worker.ts +import * as AWS from "alchemy/AWS"; ++import * as SQS from "alchemy/AWS/SQS"; ++import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; ++import * as Stream from "effect/Stream"; +import { Jobs } from "./queue.ts"; + +export default class Worker extends AWS.Lambda.Function()( + "JobsWorker", + { main: import.meta.url }, + Effect.gen(function* () { + const queue = yield* Jobs; ++ ++ yield* SQS.consumeQueueMessages(queue, { batchSize: 10 }, (stream) => ++ stream.pipe( ++ Stream.runForEach((record) => ++ Console.log(`received: ${record.body}`), ++ ), ++ ), ++ ); + + return {}; + }), +) {} +``` + +That single `consumeQueueMessages(...)` call creates the Lambda event +source mapping pointing at the queue and grants the function +`sqs:ReceiveMessage`, `DeleteMessage`, and `GetQueueAttributes` +on the queue ARN. + +## Provide the runtime layer + +The consumer-side machinery for queue subscriptions ships in +`Lambda.QueueEventSource`. Provide it at the bottom of the +function: + +```diff lang="typescript" ++import * as Layer from "effect/Layer"; +// ... + return {}; +- }), ++ }).pipe(Effect.provide(AWS.Lambda.QueueEventSource)), +) {} +``` + +## Wire the worker into the Stack + +The Worker needs to be yielded so Alchemy actually deploys it: + +```diff lang="typescript" +// alchemy.run.ts +import Api from "./src/api.ts"; ++import Worker from "./src/worker.ts"; + +export default Alchemy.Stack( + "MyApp", + { providers: AWS.providers(), state: AWS.state() }, + Effect.gen(function* () { + const api = yield* Api; ++ yield* Worker; + return { url: api.functionUrl }; + }), +); +``` + +## Deploy + +```sh +bun alchemy deploy +``` + +Two functions deploy in parallel — `Api` (with `SendMessage` +permissions) and `JobsWorker` (with `ReceiveMessage` permissions +plus an event source mapping pointing at `Jobs`). + +## Verify + +Trigger a write, then watch the consumer's logs: + +```sh +curl -X PUT --data 'hello queue' "$URL/items/q1" +bun alchemy logs JobsWorker --tail +``` + +Within a couple of seconds you'll see: + +``` +received: {"type":"job.created","id":"q1","content":"hello queue"} +``` + +## Bonus: process records in parallel + +`Stream.mapEffect` accepts a `concurrency` option, so you can +fan out per-record work without rewriting anything: + +```typescript +stream.pipe( + Stream.mapEffect( + (record) => doSomethingWith(record.body).pipe(Effect.orDie), + { concurrency: 4 }, + ), + Stream.runDrain, +); +``` + +Up to 4 records process concurrently, and the event source +mapping won't ack them until the stream completes — which is the +same delivery semantics SQS already gives you, just with +controllable concurrency on top. + +## Bonus: forward a stream to SQS with `QueueSink` + +`SendMessage(queue)` is perfect for one-off sends from a +request handler, but if you've got a `Stream` of payloads — +DynamoDB change records, S3 notifications, anything — you don't +want to call `sendMessage` per item. **`SQS.QueueSink`** is the +sink-shaped counterpart: it consumes a `Stream`, batches +items into chunks of up to 10, and ships each chunk through +`sqs:SendMessageBatch` for you. + +Skip the per-write `sendMessage` in `PUT /items/:id` and forward +the existing DynamoDB stream straight into the queue instead: + +```diff lang="typescript" +// src/api.ts +import * as Stream from "effect/Stream"; +import { Jobs } from "./queue.ts"; +// ... + +const queue = yield* Jobs; +const putItem = yield* DynamoDB.PutItem(table); +-const sendMessage = yield* SQS.SendMessage(queue); ++const sink = yield* SQS.QueueSink(queue); + +yield* DynamoDB.consumeTableChanges(table, { + streamViewType: "NEW_AND_OLD_IMAGES", +}, (stream) => + stream.pipe( ++ Stream.map((record) => ++ JSON.stringify({ ++ eventName: record.eventName, ++ keys: record.dynamodb.Keys, ++ newImage: record.dynamodb.NewImage, ++ }), ++ ), ++ Stream.run(sink), + ), +); +``` + +`QueueSink(queue)` grants `sqs:SendMessage` and +`sqs:SendMessageBatch` on the queue ARN, then returns a +`Sink` you compose with +`Stream.run`. `QueueSinkHttp` is itself implemented on top of +`SendMessageBatch`, so split the merged layer into two tiers +with `Layer.provideMerge` — sink + event sources on top, +the underlying request layers + policy on the bottom: + +```diff lang="typescript" +}).pipe( + Effect.provide( +- Layer.mergeAll( +- AWS.Lambda.BucketEventSource, +- AWS.Lambda.TableEventSource, +- DynamoDB.GetItemHttp, +- DynamoDB.PutItemHttp, +- S3.PutObjectHttp, +- S3.GetObjectHttp, +- SQS.SendMessageHttp, +- ), ++ Layer.mergeAll( ++ AWS.Lambda.BucketEventSource, ++ AWS.Lambda.TableEventSource, ++ SQS.QueueSinkHttp, ++ ).pipe( ++ Layer.provideMerge( ++ Layer.mergeAll( ++ DynamoDB.GetItemHttp, ++ DynamoDB.PutItemHttp, ++ S3.PutObjectHttp, ++ S3.GetObjectHttp, ++ SQS.SendMessageBatchHttp, ++ ), ++ ), ++ ), + ), +), +``` + +The consumer doesn't change — `JobsWorker` still subscribes via +`SQS.consumeQueueMessages(queue)` and now sees one record per DynamoDB +change, batched ten-at-a-time on the producer side. + +## Where next + +- [Kinesis](/aws/messaging/kinesis) — stream higher-volume records: + ordered, sharded, and with the same `Stream` consumer surface. +- [Process DynamoDB Streams](/aws/messaging/dynamodb-streams) — the + change-data-capture pipeline the `QueueSink` bonus above + builds on. +- [`Queue` reference](/providers/aws/sqs/queue) — FIFO queues, + dead-letter targets, and every other prop. diff --git a/.agents/skills/alchemy/references/aws/networking/_overview.md b/.agents/skills/alchemy/references/aws/networking/_overview.md new file mode 100644 index 00000000..365eb2c1 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/networking/_overview.md @@ -0,0 +1,260 @@ + + +# VPC & networking + +> The Network helper for a production-shaped VPC in one call, and the full set of EC2 networking primitives — VPCs, subnets, gateways, routes, security groups, and endpoints — for explicit control. + +Most Alchemy apps on AWS never touch a VPC. Lambda functions +serve public traffic from Function URLs, and bindings wire +access to tables, buckets, and queues without a single subnet. +Networking becomes your problem when you run an +[ECS Service](/aws/compute/ecs), an [EKS Cluster](/aws/compute/eks), or an +[EC2 Instance](/aws/compute/ec2) — all +need a VPC, subnets, and security groups — or when you attach to +anything that lives inside one. + +Alchemy gives you two altitudes: the **`Network` helper**, which +builds the standard public/private layout in one call, and the +**canonical primitives** underneath it, for when you need +explicit control. + +## The `Network` helper + +[`Network`](/providers/aws/ec2/network) creates a +production-shaped VPC from the low-level primitives — VPC, +internet gateway, one public and one private subnet per +Availability Zone (2 AZs by default), route tables, routes, and +associations — and returns every underlying resource so you can +keep composing with raw `AWS.EC2.*` APIs: + +```typescript +const network = yield* AWS.EC2.Network("AppNetwork", { + cidrBlock: "10.42.0.0/16", +}); +``` + +Subnet CIDRs are derived from the VPC block automatically, and +the outputs you'll reach for most — `vpcId`, `publicSubnetIds`, +`privateSubnetIds` — sit right on the result. + +Turn on NAT and gateway endpoints when private subnets need a +path out: + +```typescript +const network = yield* AWS.EC2.Network("AppNetwork", { + cidrBlock: "10.42.0.0/16", + availabilityZones: 2, + nat: "single", + gatewayEndpoints: ["s3"], +}); + +yield* AWS.ECS.Service("ApiService", { + cluster, + task: { + taskDefinitionArn: apiTask.taskDefinitionArn, + containerName: apiTask.containerName, + port: apiTask.port, + }, + vpcId: network.vpcId, + subnets: network.publicSubnetIds, + assignPublicIp: true, +}); +``` + +- `nat: "none"` (default) keeps private subnets isolated; + `"single"` shares one NAT gateway; `"per-az"` creates one per + Availability Zone for AZ-fault isolation. +- `gatewayEndpoints` attaches free S3/DynamoDB gateway endpoints + to the private route tables, so that traffic never crosses the + NAT. + +If `Network`'s layout fits, use it and stop reading. The rest of +this page is the same network built by hand. + +## Building it by hand + +### VPC and internet gateway + +A [`Vpc`](/providers/aws/ec2/vpc) is a private IPv4 range; an +[`InternetGateway`](/providers/aws/ec2/internetgateway) is its +door to the internet: + +```typescript +const vpc = yield* AWS.EC2.Vpc("AppVpc", { + cidrBlock: "10.80.0.0/16", + enableDnsSupport: true, + enableDnsHostnames: true, +}); + +const igw = yield* AWS.EC2.InternetGateway("AppIgw", { + vpcId: vpc.vpcId, +}); +``` + +The two DNS toggles let instances resolve names and receive +public DNS hostnames — you'll want both on for anything serving +public traffic. + +### Subnets + +[`Subnet`](/providers/aws/ec2/subnet)s carve the VPC block into +per-AZ ranges. `mapPublicIpOnLaunch` is what makes a subnet +"public" from the instance's point of view: + +```typescript +const subnetA = yield* AWS.EC2.Subnet("PublicSubnetA", { + vpcId: vpc.vpcId, + cidrBlock: "10.80.1.0/24", + availabilityZone: az1, + mapPublicIpOnLaunch: true, +}); + +const subnetB = yield* AWS.EC2.Subnet("PublicSubnetB", { + vpcId: vpc.vpcId, + cidrBlock: "10.80.2.0/24", + availabilityZone: az2, + mapPublicIpOnLaunch: true, +}); +``` + +Two subnets in two AZs is the floor for anything fronted by a +load balancer. + +### Routing + +A subnet is only public once its +[`RouteTable`](/providers/aws/ec2/routetable) sends `0.0.0.0/0` +to the internet gateway. [`Route`](/providers/aws/ec2/route) adds +the rule; +[`RouteTableAssociation`](/providers/aws/ec2/routetableassociation) +attaches the table to each subnet: + +```typescript +const routeTable = yield* AWS.EC2.RouteTable("PublicRouteTable", { + vpcId: vpc.vpcId, +}); + +yield* AWS.EC2.Route("PublicInternetRoute", { + routeTableId: routeTable.routeTableId, + destinationCidrBlock: "0.0.0.0/0", + gatewayId: igw.internetGatewayId, +}); + +yield* AWS.EC2.RouteTableAssociation("PublicAssocA", { + routeTableId: routeTable.routeTableId, + subnetId: subnetA.subnetId, +}); +yield* AWS.EC2.RouteTableAssociation("PublicAssocB", { + routeTableId: routeTable.routeTableId, + subnetId: subnetB.subnetId, +}); +``` + +### Security groups + +A [`SecurityGroup`](/providers/aws/ec2/securitygroup) is the +per-resource firewall — the thing you attach to service ENIs, +load balancers, and instances. Declare ingress and egress rules +inline: + +```typescript +const securityGroup = yield* AWS.EC2.SecurityGroup("AppSg", { + vpcId: vpc.vpcId, + description: "app traffic", + ingress: [ + { + ipProtocol: "tcp", + fromPort: 80, + toPort: 80, + cidrIpv4: "0.0.0.0/0", + description: "HTTP ingress", + }, + ], + egress: [ + { + ipProtocol: "-1", + cidrIpv4: "0.0.0.0/0", + description: "all outbound", + }, + ], +}); +``` + +Rules can also be managed as standalone +[`SecurityGroupRule`](/providers/aws/ec2/securitygrouprule) +resources when different parts of your stack contribute rules to +one group. + +## Private egress with NAT + +Private subnets reach the internet through a +[`NatGateway`](/providers/aws/ec2/natgateway) living in a +*public* subnet, addressed by an [`EIP`](/providers/aws/ec2/eip), +with the private route table pointing `0.0.0.0/0` at it: + +```typescript +const privateRouteTable = yield* AWS.EC2.RouteTable("PrivateRouteTable", { + vpcId: vpc.vpcId, +}); + +const eip = yield* AWS.EC2.EIP("NatEip", { domain: "vpc" }); + +const natGateway = yield* AWS.EC2.NatGateway("NatGateway", { + subnetId: subnetA.subnetId, // a public subnet + allocationId: eip.allocationId, + connectivityType: "public", +}); + +yield* AWS.EC2.Route("PrivateInternetRoute", { + routeTableId: privateRouteTable.routeTableId, + destinationCidrBlock: "0.0.0.0/0", + natGatewayId: natGateway.natGatewayId, +}); +``` + +This is exactly what `Network`'s `nat: "single"` option builds +(`"per-az"` repeats it per Availability Zone). + +## Gateway endpoints + +S3 and DynamoDB traffic from private subnets doesn't need NAT at +all — a Gateway [`VpcEndpoint`](/providers/aws/ec2/vpcendpoint) +routes it over AWS's network for free: + +```typescript +yield* AWS.EC2.VpcEndpoint("S3Endpoint", { + vpcId: vpc.vpcId, + serviceName: "com.amazonaws.us-east-1.s3", // match your region + vpcEndpointType: "Gateway", + routeTableIds: [privateRouteTable.routeTableId], +}); +``` + +`Network`'s `gatewayEndpoints: ["s3", "dynamodb"]` does the same +against its private route tables. + +## The full toolkit + +| Resource | What it does | +| --- | --- | +| [`Vpc`](/providers/aws/ec2/vpc) | The private IPv4 network everything else lives in | +| [`Subnet`](/providers/aws/ec2/subnet) | A per-AZ slice of the VPC's address range | +| [`InternetGateway`](/providers/aws/ec2/internetgateway) | Two-way internet access for public subnets | +| [`EgressOnlyInternetGateway`](/providers/aws/ec2/egressonlyinternetgateway) | Outbound-only IPv6 internet access | +| [`NatGateway`](/providers/aws/ec2/natgateway) | Outbound internet for private subnets | +| [`EIP`](/providers/aws/ec2/eip) | A static public IPv4 address | +| [`RouteTable`](/providers/aws/ec2/routetable) / [`Route`](/providers/aws/ec2/route) / [`RouteTableAssociation`](/providers/aws/ec2/routetableassociation) | Where each subnet's traffic goes | +| [`SecurityGroup`](/providers/aws/ec2/securitygroup) / [`SecurityGroupRule`](/providers/aws/ec2/securitygrouprule) | Stateful per-resource firewall | +| [`VpcEndpoint`](/providers/aws/ec2/vpcendpoint) | Private connectivity to AWS services | +| [`NetworkAcl`](/providers/aws/ec2/networkacl) / [`NetworkAclEntry`](/providers/aws/ec2/networkaclentry) / [`NetworkAclAssociation`](/providers/aws/ec2/networkaclassociation) | Stateless subnet-level packet filtering | + +## Where next + +- [ECS](/aws/compute/ecs) — run containers in the network you just + built, with an Alchemy-managed public ALB. +- [EC2](/aws/compute/ec2) — launch instances into it, raw or hosting an + Effect program. +- [`Network` reference](/providers/aws/ec2/network) — every prop + and everything the helper returns. diff --git a/.agents/skills/alchemy/references/aws/networking/custom-domains.md b/.agents/skills/alchemy/references/aws/networking/custom-domains.md new file mode 100644 index 00000000..72a22ec2 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/networking/custom-domains.md @@ -0,0 +1,195 @@ + + +# Custom domains with Route53 + ACM + +> Serve your site or API from your own domain — DNS-validated ACM certificates, Route 53 alias records, and the domain prop on Website resources. + +A custom domain on AWS is three moving parts: a TLS certificate (ACM), DNS +records (Route 53), and wiring both into whatever serves traffic (CloudFront +or API Gateway). For websites, Alchemy collapses all three into a single +`domain` prop; for APIs and anything bespoke, the same primitives — +`AWS.ACM.Certificate` and `AWS.Route53.Record` — are available directly. + +## The one-prop path: `domain` on Website resources + +`AWS.Website.Router`, `AWS.Website.SsrSite`, and a standalone +`AWS.Website.StaticSite` all accept the same `domain` shape: + +```typescript +const router = yield* AWS.Website.Router("WebsiteRouter", { + domain: { + name: "app.example.com", + hostedZoneId: "Z1234567890", + aliases: ["www.app.example.com"], + }, + routes: { + "/*": site.routeTarget, + }, +}); +``` + +With `hostedZoneId` set, Alchemy requests a DNS-validated ACM certificate +covering `name` plus every alias, upserts the validation records into your +zone, waits for issuance, attaches the certificate to the CloudFront +distribution, and creates an `A` alias record for each hostname. The +resource's `url` output becomes `https://app.example.com`. When a site is +attached to a `Router`, put `domain` on the Router — passing it to the +attached `StaticSite` as well is an error. + +## Bring your own certificate + +If you already have an ACM certificate, pass its ARN as `cert` and Alchemy +skips certificate creation: + +```typescript +domain: { + name: "app.example.com", + hostedZoneId: "Z1234567890", + cert: existingCertificateArn, +}, +``` + +DNS records are still managed for you — only the certificate step is +replaced. The certificate must cover the domain name and every alias you +list. + +## Manage DNS elsewhere + +Set `dns: false` to skip Route 53 entirely: + +```typescript +domain: { + name: "app.example.com", + cert: existingCertificateArn, + dns: false, +}, +``` + +No records are created, so you point your DNS provider at the distribution's +domain name yourself. `dns: false` requires `cert` — without Route 53 access +Alchemy can't complete DNS validation for a new certificate, so you must +bring one. + +## Create or reference a hosted zone + +The `domain` prop and the primitives below both key off a hosted zone ID. If +the zone lives outside your stack, pass its ID as a plain string; to manage +it in the stack, use `AWS.Route53.HostedZone`: + +```typescript +const zone = yield* AWS.Route53.HostedZone("Zone", { + name: "example.com", +}); +``` + +`zone.id` is the hosted zone ID (without the `/hostedzone/` prefix) and +`zone.nameServers` lists the authoritative name servers — set these as `NS` +records at your registrar (or parent zone) to delegate the domain. Set +`forceDestroy: true` if you want `destroy` to delete any remaining records +before deleting the zone. + +## Request a DNS-validated certificate + +`AWS.ACM.Certificate` requests a certificate and, when you give it a +`hostedZoneId`, handles validation end to end: + +```typescript +const cert = yield* AWS.ACM.Certificate("WebsiteCertificate", { + domainName: "app.example.com", + subjectAlternativeNames: ["www.app.example.com"], + hostedZoneId: zone.id, +}); +``` + +The provider upserts the DNS validation records ACM asks for into the zone, +waits for Route 53 to propagate them, and blocks until the certificate +reaches `ISSUED` — so `cert.certificateArn` is ready to attach the moment +the effect resolves. Certificates are requested in `us-east-1`, the region +CloudFront requires for viewer certificates. ACM certificates are immutable: +changing the domain name, SANs, or validation settings replaces the +certificate with a new one. + +## Alias a record to CloudFront + +`AWS.Route53.Record` manages a single record set; an `A` record with +`aliasTarget` points a hostname at a CloudFront distribution: + +```typescript +yield* AWS.Route53.Record("WebsiteAlias", { + hostedZoneId: zone.id, + name: "app.example.com", + type: "A", + aliasTarget: { + hostedZoneId: distribution.hostedZoneId, + dnsName: distribution.domainName, + }, +}); +``` + +Note that `aliasTarget.hostedZoneId` is the *distribution's* hosted zone ID +(an attribute CloudFront exposes), not your own zone's. Records are written +with `UPSERT` — create and update are the same operation — and the provider +waits for the Route 53 change to reach `INSYNC` before returning, so the +record is live when the deploy finishes. + +## Custom domain for API Gateway + +For a REST API, create an `AWS.ApiGateway.DomainName` with a certificate and +map your API's stage onto it with a `BasePathMapping`: + +```typescript +const domain = yield* AWS.ApiGateway.DomainName("ApiDomain", { + domainName: "api.example.com", + regionalCertificateArn: cert.certificateArn, + endpointConfiguration: { types: ["REGIONAL"] }, + securityPolicy: "TLS_1_2", +}); + +yield* AWS.ApiGateway.BasePathMapping("Root", { + domainName: domain.domainName, + restApiId: api.restApiId, + stage: "prod", +}); +``` + +One region caveat: a `REGIONAL` domain needs its certificate in the API's own +region, while `AWS.ACM.Certificate` always issues in `us-east-1`. Deploying +to `us-east-1` works as written; in any other region, pass the ARN of a +certificate that already exists there as `regionalCertificateArn`. + +## Point DNS at the API + +The `DomainName` resource exposes the regional endpoint to alias to: + +```typescript +yield* AWS.Route53.Record("ApiAlias", { + hostedZoneId: zone.id, + name: "api.example.com", + type: "A", + aliasTarget: { + hostedZoneId: domain.regionalHostedZoneId.as(), + dnsName: domain.regionalDomainName.as(), + }, +}); +``` + +`regionalDomainName` / `regionalHostedZoneId` are the alias target for +regional endpoints (edge-optimized domains expose +`distributionDomainName` / `distributionHostedZoneId` instead). The `.as()` +narrows the attribute's type — API Gateway only populates the regional fields +for regional endpoint configurations. + +## Where next + +- [Websites on AWS](/aws/frontend/websites) — the `domain` prop in the context of the + full websites surface +- [Deploy a static site](/aws/frontend/static-site) — Router composition and + cache invalidation +- [REST API (API Gateway v1)](/aws/apis/api-gateway) — the API this + domain fronts +- Reference: [Certificate](/providers/aws/acm/certificate), + [Record](/providers/aws/route53/record), + [HostedZone](/providers/aws/route53/hostedzone), + [DomainName](/providers/aws/apigateway/domainname) diff --git a/.agents/skills/alchemy/references/aws/observability/cloudwatch.md b/.agents/skills/alchemy/references/aws/observability/cloudwatch.md new file mode 100644 index 00000000..1a762f01 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/observability/cloudwatch.md @@ -0,0 +1,185 @@ + + +# CloudWatch + +> Declare CloudWatch dashboards as structured widget documents and metric alarms that fire SNS topics — versioned next to the Lambda functions and queues they observe. + +CloudWatch is AWS's built-in metrics store: every Lambda function, +SQS queue, and DynamoDB table publishes metrics into it +automatically. In alchemy the consuming side is code too — a +`Dashboard` is a structured widget document and an `Alarm` is a +threshold on a metric, both declared in the same Stack as the +resources they watch. A tweaked threshold or a new graph is a diff in +the PR, not a console click. + +## Dashboards as code + +`DashboardBody` is a plain object, not a JSON string — alchemy +serializes it to the document the CloudWatch API expects: + +```typescript +import * as AWS from "alchemy/AWS"; + +const dashboard = yield* AWS.CloudWatch.Dashboard("ApiHealth", { + DashboardBody: { + widgets: [ + { + type: "metric", + width: 12, + height: 6, + properties: { + title: "Lambda invocations and errors", + view: "timeSeries", + stat: "Sum", + period: 300, + metrics: [ + ["AWS/Lambda", "Invocations", "FunctionName", "api"], + ["AWS/Lambda", "Errors", "FunctionName", "api"], + ], + }, + }, + ], + }, +}); +``` + +Top-level props are PascalCase because they mirror the +`PutDashboard` API input; the widget document inside follows +CloudWatch's own dashboard-body schema (`type`, `properties`, +`metrics`, ...). Four widget types are supported: `metric` (graphs), +`text` (markdown), `alarm` (alarm status grids), and `log` (Logs +Insights queries). Each takes optional `x`/`y`/`width`/`height` grid +coordinates. + +If you omit `name`, alchemy generates a unique dashboard name from +the app, stage, and logical ID — so `prod` and `pr-42` each get +their own copy. Renaming a dashboard replaces it. + +## Wire in resource names + +Hard-coding `"api"` works until the function is renamed. Metric rows +reference metrics by dimension value, so build the body from the +function's [Output](/infrastructure-as-code/outputs) instead: + +```typescript +import * as Output from "alchemy/Output"; + +const func = yield* Api; // an AWS.Lambda.Function + +const dashboard = yield* AWS.CloudWatch.Dashboard("ApiDashboard", { + DashboardBody: func.functionName.pipe( + Output.map((functionName) => ({ + widgets: [ + { + type: "metric", + width: 12, + height: 6, + properties: { + title: "Lambda Duration", + stat: "Average", + period: 300, + metrics: [ + ["AWS/Lambda", "Duration", "FunctionName", functionName], + ], + }, + }, + ], + })), + ), +}); +``` + +`Output.map` defers building the document +until the function's physical name is known, so the dashboard always +tracks the deployed function — including across replacements. + +## Alarms + +An `Alarm` watches one metric and changes state when it crosses a +threshold. Props are PascalCase, extending the `PutMetricAlarm` API +input directly; `AlarmActions` is a list of ARN strings — most +commonly an [SNS](/aws/messaging/sns) topic that fans out to email, Slack, or +PagerDuty: + +```typescript +const alerts = yield* AWS.SNS.Topic("Alerts"); + +const errors = yield* AWS.CloudWatch.Alarm("HighErrors", { + MetricName: "Errors", + Namespace: "AWS/Lambda", + Statistic: "Sum", + Period: 60, + EvaluationPeriods: 1, + Threshold: 1, + ComparisonOperator: "GreaterThanOrEqualToThreshold", + TreatMissingData: "notBreaching", + Dimensions: [{ Name: "FunctionName", Value: func.functionName }], + AlarmActions: [alerts.topicArn], +}); +``` + +`Dimensions` scopes the alarm to one function, and Output references +like `func.functionName` resolve in place. `TreatMissingData: +"notBreaching"` keeps a quiet function out of the `INSUFFICIENT_DATA` +state. The resolved alarm exposes `alarmName`, `alarmArn`, and its +current `stateValue` as outputs. + +Alarms show up on dashboards too — an `alarm` widget renders a +status grid from ARNs: + +```typescript +{ + type: "alarm", + width: 12, + properties: { + title: "Alarms", + alarms: [errors.alarmArn], + }, +}, +``` + +To aggregate several alarms into one page-worthy signal, +`CompositeAlarm` combines their states with a boolean rule: + +```typescript +const composite = yield* AWS.CloudWatch.CompositeAlarm("HighSeverity", { + AlarmRule: 'ALARM("HighErrors") OR ALARM("HighLatency")', +}); +``` + +Like dashboards, alarms get generated per-stage names when `name` is +omitted, and renaming one replaces it. + +## Where metrics come from + +You rarely have to publish anything: AWS services emit metrics on +their own. Every Lambda function reports `Invocations`, `Errors`, +`Duration`, and `Throttles` under the `AWS/Lambda` namespace, keyed +by the `FunctionName` dimension — which is why the snippets above +work against a stock [Lambda](/aws/compute/lambda) function with zero +instrumentation. Other services follow suit (`AWS/SQS`, +`AWS/DynamoDB`, ...). + +For application-level signals — spans, structured logs, custom +metrics from your Effect code — see +[Observability](/testing/observability): Effect emits OpenTelemetry +natively, and the receiving end (datasets, monitors, notifiers) is +provisioned as resources in the same Stack, exactly like the alarms +here. + +## Where next + +- [Observability](/testing/observability) — OTel exporters as + Layers, plus the cross-cloud version of alerts-in-code. +- [Lambda](/aws/compute/lambda) — the functions these dashboards and alarms + watch. +- [Axiom](/axiom) — datasets, monitors, and notifiers when you want + telemetry outside CloudWatch. + +Reference: + +- [`Alarm` reference](/providers/aws/cloudwatch/alarm) +- [`Dashboard` reference](/providers/aws/cloudwatch/dashboard) +- [`CompositeAlarm` reference](/providers/aws/cloudwatch/compositealarm) diff --git a/.agents/skills/alchemy/references/aws/security/secrets-env.md b/.agents/skills/alchemy/references/aws/security/secrets-env.md new file mode 100644 index 00000000..daaca93b --- /dev/null +++ b/.agents/skills/alchemy/references/aws/security/secrets-env.md @@ -0,0 +1,220 @@ + + +# Secrets & env + +> Deliver API keys from .env to a Lambda with effect/Config, and graduate to AWS Secrets Manager when the secret is a shared, generated, or rotated cloud resource. + +Secrets on AWS come in two tiers. Values in your `.env` that only +your own code reads travel to the Lambda as environment variables +via `effect/Config` — one line binds at deploy time and resolves at +runtime. When the secret is a cloud resource other things consume, +it belongs in [Secrets Manager](#graduate-to-secrets-manager). The +rule: in your `.env` and read only by your code → `Config`; +shared, generated, or rotated → Secrets Manager. + +## Bind a secret from .env + +`yield* Config.Redacted(...)` in the function's Construction phase reads +your `.env` at deploy time and binds the value as a Lambda +environment variable: + +```typescript +// src/api.ts +import * as AWS from "alchemy/AWS"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; + +export default class Api extends AWS.Lambda.Function()( + "Api", + { main: import.meta.url, functionUrl: true }, + Effect.gen(function* () { + const apiKey = yield* Config.Redacted("OPENAI_API_KEY"); + + return { + fetch: Effect.gen(function* () { + return HttpServerResponse.text("ok"); + }), + }; + }), +) {} +``` + +At runtime the same line resolves from that env var — one line, +both phases. + +## Unwrap at the call site + +The value is `Redacted`; unwrap with `Redacted.value` only +where the raw string is needed: + +```typescript +fetch: Effect.gen(function* () { + return HttpServerResponse.text(`Bearer ${Redacted.value(apiKey)}`); +}), +``` + +Interpolating the wrapper itself (`` `Bearer ${apiKey}` ``) produces +the literal text ``, not the value — that's the point. + +## Plain vars and defaults + +Non-secret config uses the same mechanism with `Config.String` and +`Config.Number`: + +```typescript +const host = yield* Config.String("HOST"); +const port = yield* Config.Number("PORT").pipe(Config.withDefault(3000)); +``` + +Values JSON round-trip through the environment, so `port` comes +back as a number. Combinators re-run at runtime against the bound +source, and a default is never bound — see +[Secrets and Config](/environments/secrets) for the full semantics. + +## Resolve Config in the constructor, not in fetch + +A `Config` yielded only inside `fetch` is never discovered at +deploy time, so the env var won't exist at runtime: + +```typescript +export default class Api extends AWS.Lambda.Function()( + "Api", + { main: import.meta.url, functionUrl: true }, + Effect.gen(function* () { + return { + fetch: Effect.gen(function* () { + // 🚫 never bound — API_KEY won't exist at runtime + const apiKey = yield* Config.Redacted("API_KEY"); + }), + }; + }), +) {} +``` + +Resolve in the outer `Effect.gen`, capture in a `const`, reference +that `const` from `fetch`. + +## Graduate to Secrets Manager + +Reach for Secrets Manager when the secret is an AWS-native concern: + +- **Shared consumers** — other resources reference the secret by + ARN (Aurora clusters and RDS proxies consume password-backed JSON + secrets in exactly the shape `generateSecretString` produces). +- **Generated values** — the plaintext never enters your repo, + `.env`, or deploy logs. +- **Least-privilege reads** — access is an IAM grant on one ARN, + not an env var visible to the whole function config. +- **Rotation without redeploy** — put a new value and running + functions read it on their next call. + +## Create a secret + +`Secret` owns the metadata and current value; pass the value as +`Redacted` so it never appears in logs or state: + +```typescript +import * as AWS from "alchemy/AWS"; +import * as Redacted from "effect/Redacted"; + +const dbSecret = yield* AWS.SecretsManager.Secret("DbSecret", { + description: "Database credentials", + secretString: Redacted.make( + JSON.stringify({ username: "app", password: "super-secret" }), + ), +}); +``` + +Omit `name` for a deterministic generated name; changing `name` +replaces the secret (new ARN), everything else updates in place. +On destroy alchemy deletes with `ForceDeleteWithoutRecovery` — +removal is immediate, no recovery window. + +## Generate the value instead + +For credentials nothing outside AWS needs to see, let Secrets +Manager mint the password at deploy time: + +```typescript +const dbSecret = yield* AWS.SecretsManager.Secret("DbSecret", { + generateSecretString: { + secretStringTemplate: JSON.stringify({ username: "app" }), + generateStringKey: "password", + PasswordLength: 32, + }, +}); +``` + +The stored value is the template merged with the generated password +— `{"username":"app","password":""}` — and the plaintext +never lives in your repo, `.env`, or logs. + +## Read it from a Lambda + +`GetSecretValue` is a binding: yield it in the constructor to grant IAM at +deploy time, call it at runtime for a fresh read: + +```typescript +// src/api.ts +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; +import * as Redacted from "effect/Redacted"; + +export default class Api extends AWS.Lambda.Function()( + "Api", + { main: import.meta.url, functionUrl: true }, + Effect.gen(function* () { + const dbSecret = yield* AWS.SecretsManager.Secret("DbSecret", { + generateSecretString: { + secretStringTemplate: JSON.stringify({ username: "app" }), + generateStringKey: "password", + }, + }); + + const getDbSecret = yield* AWS.SecretsManager.GetSecretValue(dbSecret); + + return { + fetch: Effect.gen(function* () { + const value = yield* getDbSecret(); + const creds = Redacted.isRedacted(value.SecretString) + ? (JSON.parse(Redacted.value(value.SecretString)) as { + username: string; + }) + : undefined; + return yield* HttpServerResponse.json({ user: creds?.username }); + }).pipe(Effect.orDie), + }; + }).pipe(Effect.provide(AWS.SecretsManager.GetSecretValueHttp)), +) {} +``` + +The binding adds `secretsmanager:GetSecretValue` and +`secretsmanager:DescribeSecret` to the execution role, scoped to +this secret's ARN only. `SecretString` comes back as `Redacted` — +unwrap with `Redacted.value` at the point of use. The value is +fetched per call, not baked into env, so putting a new version (the +[`PutSecretValue` binding](/providers/aws/secretsmanager/putsecretvalue)) +propagates on the next read. + +## Where next + +Related: + +- [RDS](/aws/data/rds) — Aurora generates a password-backed secret in + exactly the `generateSecretString` shape; the `Connect` binding + reads it at request time. +- [Lambda](/aws/compute/lambda) — the function model env vars and bindings + attach to. +- [Secrets and Config](/environments/secrets) — the Construction/Runtime split + and transformation semantics. +- [Secrets & env on Cloudflare](/cloudflare/security/secrets-env) — the same + spine on Cloudflare. + +Reference: + +- [`Secret` reference](/providers/aws/secretsmanager/secret) +- [`GetSecretValue` reference](/providers/aws/secretsmanager/getsecretvalue) diff --git a/.agents/skills/alchemy/references/aws/setup.md b/.agents/skills/alchemy/references/aws/setup.md new file mode 100644 index 00000000..42063529 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/setup.md @@ -0,0 +1,104 @@ + + +# Setup + +> Connect Alchemy to AWS with SSO, stored access keys, or CI environment credentials. + +## Prerequisites + +- [Bun](https://bun.sh) or Node.js 22+ +- An AWS account and an IAM identity with permission to create + the resources you plan to deploy + +## Create a project + +```sh +mkdir my-app && cd my-app && bun init -y +``` + +## Install + +```sh +bun add "alchemy@latest" "effect@rc" "@effect/platform-bun@rc" "@effect/platform-node@rc" +``` + +## How Alchemy gets AWS credentials + +Connect AWS with `alchemy profile edit --add AWS` and pick an +authentication method. The choice is saved to your **`default`** +[profile](/environments/profiles) and reused on every subsequent +command; a deploy with nothing configured fails with that exact +command to run. Authentication changes only through the `profile` command. + +There are two local profile methods: + +### SSO (recommended) + +Alchemy runs `aws sso login --profile ` for you and loads +credentials from the AWS SSO cache. You pick which profile from +`~/.aws/config` to use; the account ID and region come from that +profile. When the SSO session expires, run +`alchemy profile edit --reconfigure AWS` to refresh it. + +### Stored access keys + +Paste an access key ID, secret access key, optional session +token, and region into the interactive prompt. Alchemy verifies +them against STS and saves them under +`~/.alchemy/credentials//` for future runs. + +### CI environment credentials + +When `CI=true`, Alchemy bypasses profiles and reads the standard AWS +variables directly: + +```sh +export AWS_ACCESS_KEY_ID=... +export AWS_SECRET_ACCESS_KEY=... +export AWS_SESSION_TOKEN=... # optional +export AWS_REGION=us-east-1 # or AWS_DEFAULT_REGION +``` + +The region is required. The account ID is taken from +`AWS_ACCOUNT_ID` if set, otherwise resolved once via STS +`GetCallerIdentity`. No profile is created or persisted in CI. + +## Managing credentials + +Re-run the setup at any time (e.g. to switch from stored keys to +SSO, or to configure a separate `prod` profile): + +```sh +alchemy profile edit --reconfigure AWS +alchemy profile create prod +alchemy profile edit --profile prod --add AWS +``` + +Inspect what's stored (secrets are redacted): + +```sh +alchemy profile show +``` + +See [Profiles](/environments/profiles) for switching between +profiles with `--profile` or `$ALCHEMY_PROFILE`. + +## State storage + +For AWS stacks, pass `AWS.state()` as the Stack's `state` +option. Deploy state is stored in an account-regional S3 bucket +(`alchemy-state-{accountId}-{region}-an`), created lazily on the +first deploy — the same configuration works locally and in CI +with no extra setup. For purely local iteration, +`Alchemy.localState()` writes state under `.alchemy/` next to +your code instead. + +## Next steps + +- [AWS overview](/aws) — pick a runtime and resources. +- [Lambda](/aws/compute/lambda) — deploy your first function with a + public URL. +- [Secrets & env](/aws/security/secrets-env) — profile credentials + deploy the stack; app secrets are bindings on the function. diff --git a/.agents/skills/alchemy/references/aws/tutorial/part-1.md b/.agents/skills/alchemy/references/aws/tutorial/part-1.md new file mode 100644 index 00000000..899cd833 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/tutorial/part-1.md @@ -0,0 +1,219 @@ + + +# Part 1: Your First Stack + +> Install Alchemy, create a Stack with an AWS S3 Bucket, and deploy it. + +In this first part you'll install Alchemy and Effect, create a Stack +with an AWS S3 Bucket, and deploy it — all in under five minutes. + +## Prerequisites + +- [Bun](https://bun.sh) or Node.js 22+ +- An AWS account and an IAM identity with permission to create the + resources you plan to deploy + +## Create a project + +Start with an empty directory and initialize a `package.json`: + +```sh +mkdir my-app && cd my-app && bun init -y +``` + +## Install dependencies + +Install alchemy@latest and effect@{effectVersion}: + +```sh +bun add "alchemy@latest" "effect@rc" "@effect/platform-bun@rc" "@effect/platform-node@rc" +``` + +## Create the Stack + +Every Alchemy program starts with a `Stack` — a collection of +Resources managed by Providers with state tracked between deploys. + +Create an `alchemy.run.ts` file: + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "MyApp", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { + // we'll add resources here next + }), +); +``` + +`Alchemy.Stack` is the root of every app. The first argument +(`"MyApp"`) doubles as a logical id and as the prefix Alchemy uses +when AWS asks for physical resource names. + +## How credentials resolve + +`providers: AWS.providers()` registers every AWS resource and IAM +policy binding that ships with Alchemy, and resolves credentials from +your Alchemy [profile](/environments/profiles). Connect SSO or stored access +keys with `alchemy profile edit --add AWS`. See +[Setup](/aws/setup) for the details of each method. + +## Where state lives + +`state: AWS.state()` stores deploy state in an account-regional S3 +bucket (`alchemy-state-{accountId}-{region}-an`), created lazily on +the first deploy. Because the state lives in your AWS account rather +than on disk, the same configuration works locally and in CI with no +extra setup. + +:::note[Other state stores] +For purely local iteration you can use `Alchemy.localState()` +instead, which writes state under `.alchemy/` next to your code — or +[write your own](/state-store). +::: + +## Add a Resource + +Resources represent cloud infrastructure — buckets, tables, queues, +functions, and so on. Each resource is `yield*`-ed inside the Stack's +Effect generator. + +Add an S3 Bucket: + +```diff lang="typescript" +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; + +export default Alchemy.Stack( + "MyApp", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { ++ const bucket = yield* AWS.S3.Bucket("Bucket"); + }), +); +``` + +`yield* AWS.S3.Bucket("Bucket")` registers the resource with the +Stack under the logical id `Bucket` and returns a typed `Bucket` +handle. We didn't pass a `bucketName`, so Alchemy generates a unique +physical name from the app, stage, and logical id. + +## Return Stack outputs + +Stack outputs let you see important values after a deploy. Return an +object from the generator to expose them: + +```diff lang="typescript" + Effect.gen(function* () { + const bucket = yield* AWS.S3.Bucket("Bucket"); ++ ++ return { ++ bucketName: bucket.bucketName, ++ }; + }), +``` + +`bucket.bucketName` is an Output — a typed reference to an attribute +the cloud will produce. It resolves during deploy and prints with the +rest of the stack outputs. + +## Deploy + +Run `alchemy deploy` to create the Bucket on AWS: + +```sh +bun alchemy deploy +``` + +Connect AWS first with `alchemy profile edit --add AWS` and pick a +local authentication method: **SSO** (recommended) or **stored access +keys**. The choice is saved to your **`default`** +[profile](/environments/profiles); the region and account come from +whichever method you pick, and a deploy with nothing configured fails +with that exact command to run. CI instead reads AWS environment +credentials directly. See [Setup](/aws/setup) for the full walkthrough. + +:::tip +To re-run the credential prompt later (e.g. to switch from stored +keys to SSO), use `alchemy profile edit --reconfigure AWS`. For a +separate profile, run `alchemy profile create prod` followed by +`alchemy profile edit --profile prod`. +::: + +```text +Plan: 1 to create ++ Bucket (AWS.S3.Bucket) + +Proceed? +◉ Yes ○ No +✓ Bucket (AWS.S3.Bucket) created +{ + bucketName: "myapp-bucket-a1b2c3d4e5", +} +``` + +Alchemy shows a plan, asks for confirmation, creates the resource, +and prints the stack outputs. Your bucket is live on AWS. + +:::tip +By convention, Alchemy looks for `alchemy.run.ts` at the root of your +project. You can specify a different file with +`alchemy deploy --config `. +::: + +## Verify it worked + +Your newly created bucket will be listed in the S3 section of the AWS +Console (or via `aws s3 ls` if you have the AWS CLI installed). + +Run `alchemy deploy` again. Because nothing changed, the bucket shows +as a no-op: + +```text +Plan: no changes + +{ + bucketName: "myapp-bucket-a1b2c3d4e5", +} +``` + +This is the core loop — declare resources in code, deploy, and +Alchemy figures out what changed. + +## Destroy (optional) + +The inverse of `deploy` is `destroy` — it plans against an empty +desired state and deletes everything the stack created: + +```sh +bun alchemy destroy +``` + +Try it if you like, then run `bun alchemy deploy` again to bring the +bucket back — we'll build on it in Part 2. + +## Recap + +You now have: + +- An `alchemy.run.ts` with a Stack and an AWS S3 Bucket +- A live bucket deployed to your AWS account +- Stack outputs showing the bucket name + +In [Part 2](/aws/tutorial/part-2), you'll add a Lambda Function with +a public URL that reads and writes objects in this bucket. diff --git a/.agents/skills/alchemy/references/aws/tutorial/part-2.md b/.agents/skills/alchemy/references/aws/tutorial/part-2.md new file mode 100644 index 00000000..4f5470c6 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/tutorial/part-2.md @@ -0,0 +1,406 @@ + + +# Part 2: Add a Lambda + +> Create an AWS Lambda Function with a public URL, bind the S3 Bucket, and implement GET/PUT routes. + +In [Part 1](/aws/tutorial/part-1) you deployed an S3 Bucket. Now +you'll create a Lambda Function with a public URL that reads and +writes objects in that bucket over HTTP. + +## Create the function file + +A Lambda Function in Alchemy is a class — it has both an +infrastructure definition and a runtime implementation expressed as +an Effect. Create `src/api.ts` with the smallest possible +declaration: + +```typescript +// src/api.ts +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; + +export default class Api extends AWS.Lambda.Function()( + "Api", + { main: import.meta.url }, + Effect.gen(function* () { + return {}; + }), +) {} +``` + +The `` type argument plus the empty `()` is a one-time bit of +ceremony — it lets TypeScript reason about `Api` as a typed handle +that other resources can later bind against. `main: import.meta.url` +tells Alchemy this same file is the bundle entrypoint: at deploy time +it's bundled with Rolldown into a zip and uploaded as the function's +code. + +## Serve HTTP with `fetch` + +The empty function compiles, but it doesn't do anything yet. Add a +`fetch` field — Alchemy treats anything returned from the +`Effect.gen` block as the runtime API, and `fetch` specifically is +wired up to handle incoming HTTP requests: + +```diff lang="typescript" +// src/api.ts +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; ++import * as HttpServerResponse from "effect/http/HttpServerResponse"; + +export default class Api extends AWS.Lambda.Function()( + "Api", + { main: import.meta.url }, + Effect.gen(function* () { +- return {}; ++ return { ++ fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), ++ }; + }), +) {} +``` + +`HttpServerResponse.text(...)` is the same `effect/http` API +used everywhere else in Effect — Alchemy adapts it to the Lambda +event envelope under the hood, so your handler never sees the raw +`APIGatewayProxyEvent` shape. + +:::note +Notice the "Effect returning an Effect" pattern. The outer generator +is the **Construction phase** — it runs at both plan time and runtime. The +inner `fetch` generator is the **Runtime phase** — it runs only when +handling a request. +::: + +## Expose a public URL + +`fetch` exists, but no one can call it yet. Set `functionUrl: true` on the +props to ask AWS for a public **Function URL** — no API Gateway, no +auth, just a public HTTPS endpoint: + +```diff lang="typescript" +export default class Api extends AWS.Lambda.Function()( + "Api", +- { main: import.meta.url }, ++ { main: import.meta.url, functionUrl: true }, + Effect.gen(function* () { + return { + fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), + }; + }), +) {} +``` + +The resolved `Api` resource will now expose a `functionUrl` field +carrying that endpoint — we'll surface it from the Stack in a moment. + +## Move the Bucket into the function + +In Part 1 the Bucket lived in `alchemy.run.ts`, but resources can be +declared inside any Effect the Stack runs — including a function's +Construction phase. Declaring the bucket next to the code that uses it keeps +everything about the function in one file. Add it to the constructor: + +```diff lang="typescript" +// src/api.ts +import * as AWS from "alchemy/AWS"; ++import * as S3 from "alchemy/AWS/S3"; +import * as Effect from "effect/Effect"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; + +export default class Api extends AWS.Lambda.Function()( + "Api", + { main: import.meta.url, functionUrl: true }, + Effect.gen(function* () { ++ const bucket = yield* S3.Bucket("Bucket"); + + return { + fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), + }; + }), +) {} +``` + +The logical id is still `Bucket`, so Alchemy recognizes it as the +same resource you deployed in Part 1 — moving a declaration between +files doesn't recreate anything. + +## Bind PutObject and GetObject + +S3 operations like `s3:PutObject` and `s3:GetObject` are exposed as +**bindings** — a typed runtime function you call from your handler, +plus an IAM policy statement that gets attached to the function role +automatically, scoped to the exact bucket ARN. Bind them in the outer +constructor, alongside the bucket: + +```diff lang="typescript" + Effect.gen(function* () { + const bucket = yield* S3.Bucket("Bucket"); ++ const putObject = yield* S3.PutObject(bucket); ++ const getObject = yield* S3.GetObject(bucket); + + return { + fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), + }; + }), +``` + +Both capability calls return a callable Effect: `putObject` takes a +`PutObjectRequest` (the AWS SDK shape, minus the `Bucket` field +Alchemy fills in for you) and `getObject` takes a `GetObjectRequest`. +Each call is 1:1 with an IAM statement on the execution role — delete +a capability call and the matching statement disappears next deploy. +See [S3 → One binding, one IAM statement](/aws/data/s3#one-binding-one-iam-statement) +for the exact mapping. + +## Write objects with `PUT /:key` + +Replace the static handler with one that takes the URL path as the +object key and writes the request body to the bucket: + +```diff lang="typescript" +// src/api.ts +import * as AWS from "alchemy/AWS"; +import * as S3 from "alchemy/AWS/S3"; +import * as Effect from "effect/Effect"; ++import { HttpServerRequest } from "effect/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; + +// ... + return { +- fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), ++ fetch: Effect.gen(function* () { ++ const request = yield* HttpServerRequest; ++ const key = new URL(request.url).pathname.slice(1); ++ ++ if (request.method === "PUT") { ++ const body = yield* request.text; ++ yield* putObject({ Key: key, Body: body }); ++ return HttpServerResponse.empty({ status: 204 }); ++ } ++ ++ return HttpServerResponse.text("Method not allowed", { status: 405 }); ++ }), + }; +``` + +`putObject({ Key, Body })` is the same shape as `s3:PutObject` in the +AWS SDK, minus the `Bucket` field — Alchemy fills that in from the +bucket you bound, so the call site stays focused on what's actually +variable. + +## Read objects with `GET /:key` + +Add a `GET` branch that fetches the object and pipes it back to the +client: + +```diff lang="typescript" +if (request.method === "PUT") { + const body = yield* request.text; + yield* putObject({ Key: key, Body: body }); + return HttpServerResponse.empty({ status: 204 }); +} + ++if (request.method === "GET") { ++ const result = yield* getObject({ Key: key }); ++ return HttpServerResponse.stream(result.Body!); ++} + +return HttpServerResponse.text("Method not allowed", { status: 405 }); +``` + +`result.Body` is an Effect `Stream`, not a buffered +`Buffer`. `HttpServerResponse.stream` flushes each chunk to the HTTP +response as it arrives, so even a multi-gigabyte object moves through +the function without ever being held in memory. + +## Handle missing objects + +If the key doesn't exist, S3 responds with `NoSuchKey`. The binding +surfaces that as a typed error tag on the Effect, so +`Effect.catchTag` recovers from it without inspecting strings: + +```diff lang="typescript" +if (request.method === "GET") { +- const result = yield* getObject({ Key: key }); ++ const result = yield* getObject({ Key: key }).pipe( ++ Effect.catchTag("NoSuchKey", () => Effect.succeed(undefined)), ++ ); ++ if (!result?.Body) { ++ return HttpServerResponse.text("Not found", { status: 404 }); ++ } + return HttpServerResponse.stream(result.Body!); +} +``` + +Every binding's failure channel is enumerated this way: the AWS SDK +error names become Effect tags, so the type-checker tells you which +failures the call site needs to handle. + +## Convert unhandled errors to 500s + +Any failure you didn't `catchTag` is a programmer error — a missing +IAM grant, a transient AWS outage, a bug. Surface those as 500s by +applying `Effect.orDie` once at the request boundary, rather than per +call: + +```diff lang="typescript" +fetch: Effect.gen(function* () { + // ... PUT and GET branches ... + return HttpServerResponse.text("Method not allowed", { status: 405 }); +-}), ++}).pipe(Effect.orDie), +``` + +Applying `orDie` once at the outer layer keeps the inner code free of +repetitive error plumbing — the only errors you handle explicitly are +the ones you actually have a recovery for (`NoSuchKey`, in this +case). + +## Provide the runtime layers + +Bindings declare a capability; **runtime layers** implement it. For +S3 those are `S3.PutObjectHttp` and `S3.GetObjectHttp` — both ship in +`alchemy/AWS/S3` and depend only on the AWS SDK and the ambient +credentials. Provide them at the bottom of the function: + +```diff lang="typescript" +// src/api.ts ++import * as Layer from "effect/Layer"; +// ... +export default class Api extends AWS.Lambda.Function()( + "Api", + { main: import.meta.url, functionUrl: true }, + Effect.gen(function* () { + /* ... bucket + bindings + fetch ... */ +- }), ++ }).pipe( ++ Effect.provide(Layer.mergeAll(S3.PutObjectHttp, S3.GetObjectHttp)), ++ ), +) {} +``` + +`Layer.mergeAll` unions multiple layers into one — no order required +— and `Effect.provide` satisfies the binding requirements declared by +`PutObject` and `GetObject`. + +## Wire the function into the Stack + +The `Api` class is just a typed identifier — yielding it inside the +Stack's Effect is what registers the resource and starts the deploy: + +```diff lang="typescript" +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; ++import Api from "./src/api.ts"; + +export default Alchemy.Stack( + "MyApp", + { + providers: AWS.providers(), + state: AWS.state(), + }, + Effect.gen(function* () { + const bucket = yield* AWS.S3.Bucket("Bucket"); ++ const api = yield* Api; + + return { + bucketName: bucket.bucketName, ++ url: api.functionUrl, + }; + }), +); +``` + +Yielding `Api` returns the resolved Lambda outputs — the function +ARN, role ARN, and the public Function URL we asked for with +`functionUrl: true`. We surface `functionUrl` as the Stack's `url` output. + +## Retire the inline Bucket + +The Bucket is now declared inside `Api`'s constructor, so the copy in +`alchemy.run.ts` is redundant — remove it: + +```diff lang="typescript" + Effect.gen(function* () { +- const bucket = yield* AWS.S3.Bucket("Bucket"); + const api = yield* Api; + + return { +- bucketName: bucket.bucketName, + url: api.functionUrl, + }; + }), +``` + +The bucket is still part of the Stack — it's registered when `Api`'s +Construction phase runs — it just isn't surfaced as a stack output anymore. + +## Deploy + +Deploy again. Alchemy detects the new function and the unchanged +bucket: + +```sh +bun alchemy deploy +``` + +```text +Plan: 1 to create + ++ Api (AWS.Lambda.Function) +• Bucket (AWS.S3.Bucket) + +Proceed? +◉ Yes ○ No +• Bucket (AWS.S3.Bucket) no change +✓ Api (AWS.Lambda.Function) created +{ + url: "https://abc123xyz.lambda-url.us-east-1.on.aws", +} +``` + +Alchemy bundles `src/api.ts` with Rolldown, packages it into a zip, +creates the IAM execution role with the two S3 statements, uploads +the function, and provisions the Function URL. The first deploy takes +a moment because of role propagation; subsequent deploys are seconds. + +## Try it out + +Use `curl` to write and read an object: + +```sh +# Store an object +curl -X PUT https://abc123xyz.lambda-url.us-east-1.on.aws/hello.txt \ + -d 'Hello, world!' + +# Retrieve it +curl https://abc123xyz.lambda-url.us-east-1.on.aws/hello.txt +# → Hello, world! +``` + +## Recap + +You now have: + +- A Lambda Function with GET and PUT routes served over a public + Function URL +- An S3 Bucket bound to the function, with the IAM policy generated + from the bindings you actually use — no policy JSON in sight +- Stack outputs showing the function URL + +In [Part 3](/aws/tutorial/part-3), you'll write integration tests +that deploy the stack and drive it over HTTP. + +:::tip[Want to start building now?] +You have a deployed function — that's enough to skip ahead to +[DynamoDB](/aws/data/dynamodb), [SQS](/aws/messaging/sqs), or +[Kinesis](/aws/messaging/kinesis) if you'd rather build something interesting +first and circle back to testing, stages, and CI/CD when you need +them. +::: diff --git a/.agents/skills/alchemy/references/aws/tutorial/part-3.md b/.agents/skills/alchemy/references/aws/tutorial/part-3.md new file mode 100644 index 00000000..bb10b6f0 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/tutorial/part-3.md @@ -0,0 +1,240 @@ + + +# Part 3: Testing + +> Write integration tests that deploy your stack and make HTTP requests against your live Lambda Function URL. + +In [Part 2](/aws/tutorial/part-2) you deployed a Lambda with S3 +bindings. Now you'll write integration tests that deploy the stack, +hit the live Function URL over HTTP, and verify it works. + +## Create the test file + +Alchemy ships test utilities for Bun that wrap `bun:test` with Effect +support. Configure providers (and state) once at the top of the file +with `Test.make({...})` — the same `AWS.providers()` / `AWS.state()` +you used in your `Stack`: + +```typescript +// test/integ.test.ts +import * as AWS from "alchemy/AWS"; +import * as Test from "alchemy/Test/Bun"; +import { expect } from "bun:test"; +import * as Effect from "effect/Effect"; +import Stack from "../alchemy.run.ts"; + +const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ + providers: AWS.providers(), + state: AWS.state(), +}); +``` + +`expect` (and any other `bun:test` helpers like `describe`) come from +`bun:test` directly — `Test.make` only provides the Effect-aware test +runner pieces. + +:::note[Using Vitest?] +`alchemy/Test/Vitest` exposes the exact same API — swap the import +and take `expect` from `@effect/vitest` instead. See +[Test harness → Bun vs Vitest](/testing/test-harness#bun-vs-vitest). +::: + +## Deploy the stack before tests + +Use `beforeAll` with `deploy` to deploy your stack once before any +tests run: + +```diff lang="typescript" +const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ + providers: AWS.providers(), + state: AWS.state(), +}); + ++const stack = beforeAll(deploy(Stack)); +``` + +`deploy(Stack)` returns an Effect that plans and applies the stack. +`beforeAll` runs it once, then returns a lazy accessor you can +`yield*` inside each test to get the stack outputs. + +## Access the stack outputs + +Write your first test. Use `yield* stack` to get the outputs you +returned from your Stack in Part 2: + +```diff lang="typescript" +const stack = beforeAll(deploy(Stack)); + ++test( ++ "stack exposes a function url", ++ Effect.gen(function* () { ++ const { url } = yield* stack; ++ ++ expect(url).toBeString(); ++ }), ++); +``` + +`test(name, effect)` wraps `bun:test` — you write an Effect generator +instead of an async function. + +## Run the tests + +```sh +bun test test/integ.test.ts +``` + +The first run deploys the stack (or reuses the existing one if +already deployed). Subsequent runs are fast because Alchemy diffs and +skips unchanged resources. + +:::note[Which stage do tests deploy to?] +The test harness defaults to `test_$USER` (e.g. `test_sam`), +separate from the `live_$USER` / `dev_$USER` stages your +`alchemy deploy` / `alchemy dev` runs target — so tests never +clobber your working deployment, and two people running the suite +don't collide. More on stages in [Part 4](/aws/tutorial/part-4). +::: + +## Add HTTP assertions + +The basic test just checks that a URL exists. Let's verify the +function actually handles requests: + +```diff lang="typescript" +// test/integ.test.ts +import * as AWS from "alchemy/AWS"; +import * as Test from "alchemy/Test/Bun"; +import { expect } from "bun:test"; +import * as Effect from "effect/Effect"; ++import * as HttpBody from "effect/http/HttpBody"; ++import * as HttpClient from "effect/http/HttpClient"; +import Stack from "../alchemy.run.ts"; + +const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ + providers: AWS.providers(), + state: AWS.state(), +}); + +const stack = beforeAll(deploy(Stack)); + +-test( +- "stack exposes a function url", +- Effect.gen(function* () { +- const { url } = yield* stack; +- +- expect(url).toBeString(); +- }), +-); ++test( ++ "PUT and GET round-trip an object", ++ Effect.gen(function* () { ++ const { url } = yield* stack; ++ ++ const put = yield* HttpClient.put(`${url}/hello.txt`, { ++ body: HttpBody.text("Hello, World!"), ++ }); ++ expect(put.status).toBe(204); ++ ++ const get = yield* HttpClient.get(`${url}/hello.txt`); ++ expect(yield* get.text).toBe("Hello, World!"); ++ }), ++); ++ ++test( ++ "GET missing key returns 404", ++ Effect.gen(function* () { ++ const { url } = yield* stack; ++ const response = yield* HttpClient.get(`${url}/no-such-key`); ++ expect(response.status).toBe(404); ++ }), ++); +``` + +`HttpClient` is provided automatically by the test harness — no extra +setup needed. + +:::tip[Cold starts] +A freshly created Function URL can take a few seconds before it +serves its first request — DNS propagation, IAM propagation, and the +function's own cold start all stack up. If the very first request in +a fresh stage flakes, retry it with a bounded schedule: + +```typescript +import * as Schedule from "effect/Schedule"; + +const get = yield* HttpClient.get(`${url}/hello.txt`).pipe( + Effect.retry({ schedule: Schedule.exponential("500 millis"), times: 10 }), +); +``` +::: + +## Allow the Bucket to be destroyed with objects in it + +The PUT test writes `hello.txt` into the Bucket, so the Bucket is no +longer empty when the tests finish. S3 refuses to delete a Bucket +that still contains objects, and Alchemy keeps that protection by +default: destroying a non-empty Bucket fails with `BucketNotEmpty` and +leaves the Bucket and its objects in place. + +This Bucket only ever holds test data, so opt in to deleting its +objects on destroy with `forceDestroy: true`: + +```diff lang="typescript" +// src/api.ts + Effect.gen(function* () { +- const bucket = yield* S3.Bucket("Bucket"); ++ const bucket = yield* S3.Bucket("Bucket", { ++ forceDestroy: true, ++ }); + const putObject = yield* S3.PutObject(bucket); + const getObject = yield* S3.GetObject(bucket); +``` + +With `forceDestroy: true`, Alchemy empties the Bucket before deleting +it. Reserve it for Buckets whose contents are disposable (test +fixtures, caches, previews); leave it off for Buckets that hold data +you need to keep. + +## Destroy after tests on CI + +Right now the stack stays deployed after tests finish. That's great +locally — you can re-run tests instantly against the already-deployed +stack. But on CI you want to clean up. + +Add `afterAll` with `destroy`, using `skipIf` to only tear down when +`CI` is set: + +```diff lang="typescript" +const stack = beforeAll(deploy(Stack)); + ++afterAll.skipIf(!process.env.CI)(destroy(Stack)); + +test(/* .. */); + +test(/* .. */); +``` + +- **Locally** — `CI` is not set, so `skipIf` skips the destroy. You + iterate fast against the live stack. +- **On CI** — set `CI=true` and the stack is torn down automatically + after tests complete. + +## Recap + +You now have: + +- `Test.make({ providers, state })` to wire your provider Layer and + state store into the test runner once per file +- `beforeAll(deploy(Stack))` to deploy once before tests +- `yield* stack` to access outputs in each test +- HTTP assertions using Effect's `HttpClient` +- `forceDestroy: true` so destroy can delete a Bucket that tests wrote to +- `afterAll.skipIf(!process.env.CI)(destroy(Stack))` for automatic + cleanup on CI with fast iteration locally + +In [Part 4](/aws/tutorial/part-4), you'll use stages to give every +developer, environment, and pull request its own isolated copy of the +stack. diff --git a/.agents/skills/alchemy/references/aws/tutorial/part-4.md b/.agents/skills/alchemy/references/aws/tutorial/part-4.md new file mode 100644 index 00000000..86096462 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/tutorial/part-4.md @@ -0,0 +1,174 @@ + + +# Part 4: Stages + +> Deploy isolated dev, staging, and prod instances of your stack with --stage, and tune resources per stage. + +In [Part 3](/aws/tutorial/part-3) you wrote integration tests against +a deployed stack. But so far everything has landed in a single +environment. In this part you'll use **stages** — isolated instances +of the same Stack — to give every developer, environment, and (in +[Part 5](/aws/tutorial/part-5)) every pull request its own copy of +the infrastructure. + +## The default stage + +You've been using stages all along. If you don't pass `--stage`, +Alchemy deploys to **`live_$USER`** (e.g. `live_sam`): + +```sh +$ whoami +sam + +$ bun alchemy deploy +# deploys to stage `live_sam` +``` + +Each developer on your team automatically gets a personal sandbox +without any config. The resolution order is: + +1. `--stage ` flag +2. `$ALCHEMY_STAGE` environment variable +3. `live_${USER}` (or `live_${USERNAME}` on Windows) +4. `live_unknown` if no user is set + +## Deploy a second stage + +Pass `--stage` to deploy a completely separate instance of the same +program: + +```sh +bun alchemy deploy --stage staging +``` + +```text +Plan: 2 to create ++ Api (AWS.Lambda.Function) ++ Bucket (AWS.S3.Bucket) + +Proceed? +◉ Yes ○ No +✓ Bucket (AWS.S3.Bucket) created +✓ Api (AWS.Lambda.Function) created +{ + url: "https://def456uvw.lambda-url.us-east-1.on.aws", +} +``` + +Everything is created fresh — a new bucket, a new function, a new +Function URL — because `staging` has never been deployed before. +Your `dev_sam` deployment is untouched. + +## How stages isolate + +Each stage gets its own: + +- **State** — the persisted record of what's deployed, kept + separately per stage in the `AWS.state()` S3 bucket +- **Physical names** — `myapp-prod-bucket-7d4e` vs + `myapp-dev_sam-bucket-9b2c`; the stage is baked into every + generated resource name +- **Logs and metrics** — scoped per deployed function + +Because of this, deploying or destroying one stage **never touches** +another: + +```sh +alchemy deploy --stage prod +alchemy deploy --stage pr-42 +alchemy destroy --stage pr-42 # only removes pr-42 resources +``` + +Stage names must match `[a-z0-9][-_a-z0-9]*`. + +## Configure per stage + +The same program deploys to every stage, but you'll often want +different knobs per environment — more Lambda memory in prod, say. +`Stack.useSync` gives module-scope resources a synchronous accessor +to the surrounding Stack context, including the current `stage`: + +```diff lang="typescript" +// src/api.ts +import * as AWS from "alchemy/AWS"; +import * as S3 from "alchemy/AWS/S3"; ++import { Stack } from "alchemy/Stack"; +import * as Effect from "effect/Effect"; +// ... + +export default class Api extends AWS.Lambda.Function()( + "Api", +- { main: import.meta.url, functionUrl: true }, ++ Stack.useSync((stack) => ({ ++ main: import.meta.url, ++ functionUrl: true, ++ memory: stack.stage === "prod" ? 1024 : 512, ++ })), + Effect.gen(function* () { + /* ... */ + }), +) {} +``` + +Now `alchemy deploy --stage prod` provisions a 1024 MB function while +every other stage stays at 512 MB — one program, per-stage +configuration, no separate config files. + +## Destroy a stage + +When you're done with an environment, tear down just that stage: + +```sh +bun alchemy destroy --stage staging +``` + +The plan lists everything in `staging` for deletion and leaves every +other stage alone. + +## Stages in tests + +The test harness from Part 3 defaults to `test_$USER` (e.g. +`test_sam`). Override it per file — or per call — which is how +multiple CI runs can test in parallel against the same AWS account +without colliding: + +```diff lang="typescript" +// test/integ.test.ts +const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ + providers: AWS.providers(), + state: AWS.state(), ++ stage: `pr-${process.env.PR_NUMBER}`, +}); +``` + +See [Test harness → stage](/testing/test-harness#stage) for the per-call form +(`deploy(Stack, { stage: "ci-pr-42" })`). + +## Stage vs profile + +Stages isolate **what** is deployed (state, physical names). +[Profiles](/environments/profiles) isolate **how** Alchemy authenticates +to AWS. They're orthogonal — you can pair a `prod` stage with a +`prod` profile that points at a separate AWS account, or use the same +credentials across many stages: + +```sh +alchemy deploy --stage prod --profile prod +alchemy deploy --stage pr-42 --profile default +``` + +## Recap + +You now have: + +- A personal `dev_` stage that every plain `alchemy deploy` + targets +- A `staging` stage deployed (and destroyed) with `--stage` +- Per-stage Lambda memory via `Stack.useSync` +- Tests pinned to their own stage + +In [Part 5](/aws/tutorial/part-5), you'll put stages to work in CI: +GitHub Actions deploys `prod` on pushes to `main` and an isolated +`pr-` preview stage for every pull request. diff --git a/.agents/skills/alchemy/references/aws/tutorial/part-5.md b/.agents/skills/alchemy/references/aws/tutorial/part-5.md new file mode 100644 index 00000000..1a6c8298 --- /dev/null +++ b/.agents/skills/alchemy/references/aws/tutorial/part-5.md @@ -0,0 +1,448 @@ + + +# Part 5: CI/CD + +> Set up GitHub Actions for automated AWS deployments and PR previews — with OIDC credentials provisioned as code. + +In [Part 4](/aws/tutorial/part-4) you deployed isolated stages by +hand. Now you'll hand it off to GitHub Actions: pushes to `main` +deploy to production, pull requests get isolated preview +environments, and merged PRs clean up after themselves. + +The trick to doing this safely is **letting Alchemy provision its own +CI credentials**. Instead of pasting long-lived AWS access keys into +GitHub, you'll write a small `stacks/github.ts` that creates a GitHub +**OIDC provider** and an IAM **role** scoped to your repo — CI +assumes the role at runtime and no secret ever exists to leak. + +## Confirm your state store is remote + +CI needs to share state across runs, so a local `.alchemy/` directory +won't cut it. You already configured `AWS.state()` back in +[Part 1](/aws/tutorial/part-1#where-state-lives), which stores state +in an account-regional S3 bucket. Every deploy — local or from CI — +reads and writes state through that bucket. + +Double-check it's still wired up in `alchemy.run.ts`: + +```typescript +// alchemy.run.ts +export default Alchemy.Stack( + "MyApp", + { + providers: AWS.providers(), + state: AWS.state(), + }, + // ... +); +``` + +:::caution +If you switched to `Alchemy.localState()` at some point, switch back +to `AWS.state()` (or another shared store) before continuing — GitHub +Actions runners can't see your local filesystem. +::: + +## Set up an `admin` profile + +Your `alchemy.run.ts` only needs enough permission to deploy your +app. The `stacks/github.ts` you're about to write needs more: it +creates an IAM `OpenIDConnectProvider` and an IAM `Role`, which +requires IAM admin rights. + +Rather than hand those elevated rights to your day-to-day profile, +create a dedicated `admin` profile: + +```sh +alchemy profile create admin +``` + +Connect AWS to the new profile: + +```sh +alchemy profile edit --profile admin --add AWS +``` + +Choose an AWS credential (SSO, access keys, etc.) that's authorized +for IAM administration. + +:::danger[Heads up] +The credential stored under `--profile admin` can create IAM roles +with arbitrary permissions. Treat it like root. Do **not** use it for +everyday `alchemy deploy` runs against your application — keep your +default profile on a narrowly scoped identity and only reach for +`admin` when you're touching `stacks/github.ts`. +::: + +You'll also be prompted for a GitHub credential. Pick `gh-cli` if you +have the GitHub CLI installed, otherwise paste a Personal Access +Token with the `repo` scope. + +## Scaffold the GitHub stack + +Create `stacks/github.ts` with an empty stack that loads both +provider layers and reuses your remote state store. This is a +one-shot stack you'll deploy from your laptop (under the `admin` +profile) to provision the IAM role CI will use. + +```typescript +// stacks/github.ts +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as GitHub from "alchemy/GitHub"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export default Alchemy.Stack( + "github", + { + providers: Layer.mergeAll( + AWS.providers(), + GitHub.providers(), + ), + state: AWS.state(), + }, + Effect.gen(function* () { + // OIDC provider + role go here next + }), +); +``` + +Reusing the same `AWS.state()` you set up in Part 1 means the role's +ARN is tracked alongside the rest of your infrastructure, so any +future edit (rename, policy change) produces a clean diff. + +## Create the OIDC provider + +Add an `OpenIDConnectProvider` that tells AWS to trust tokens issued +by GitHub Actions: + +```diff lang="typescript" + Effect.gen(function* () { ++ const oidc = yield* AWS.IAM.OpenIDConnectProvider("GitHubOidc", { ++ url: "https://token.actions.githubusercontent.com", ++ clientIDList: ["sts.amazonaws.com"], ++ // GitHub's well-known OIDC thumbprint. AWS auto-discovers ++ // thumbprints for github.com these days, but the provider's ++ // thumbprint sync still requires a non-empty list. ++ thumbprintList: ["6938fd4d98bab03faadb97b34396831e3780aea1"], ++ }); + }), +``` + +With this in place, GitHub's token issuer becomes a federated +identity provider in your AWS account — the raw material for +short-lived, secretless CI credentials. + +## Create the deploy role + +Add the IAM role CI will assume. The trust policy restricts who can +assume it to workflows from **your specific repo**: + +```diff lang="typescript" + }); ++ ++ const role = yield* AWS.IAM.Role("GitHubActionsRole", { ++ roleName: "github-actions", ++ assumeRolePolicyDocument: { ++ Version: "2012-10-17", ++ Statement: [ ++ { ++ Effect: "Allow", ++ Principal: { ++ Federated: oidc.openIDConnectProviderArn, ++ }, ++ Action: ["sts:AssumeRoleWithWebIdentity"], ++ Condition: { ++ StringEquals: { ++ "token.actions.githubusercontent.com:aud": ++ "sts.amazonaws.com", ++ }, ++ StringLike: { ++ "token.actions.githubusercontent.com:sub": ++ "repo:your-org/your-repo:*", ++ }, ++ }, ++ }, ++ ], ++ }, ++ managedPolicyArns: [ ++ "arn:aws:iam::aws:policy/AdministratorAccess", ++ ], ++ }); + }), +``` + +Replace `your-org/your-repo` with your GitHub org and repo slug. + +:::tip[Tighten the role] +`AdministratorAccess` is the path of least resistance for getting CI +working, but for production you should swap it for a custom-managed +policy that grants only the actions your app needs (for example, +`lambda:*`, `s3:*` on your buckets, and `iam:` actions for the +execution roles Alchemy manages). The `assumeRolePolicyDocument` +already restricts who can assume the role to your specific repo via +the `sub` condition. +::: + +## Publish the role ARN and region + +The workflow needs to know which role to assume and in which region +to operate. Neither is sensitive, so write them as GitHub Actions +**variables** (not secrets): + +```diff lang="typescript" + }); ++ ++ const region = yield* AWS.Region; ++ ++ yield* GitHub.Variable("aws-role-arn", { ++ owner: "your-org", ++ repository: "your-repo", ++ name: "AWS_ROLE_ARN", ++ value: role.roleArn, ++ }); ++ ++ yield* GitHub.Variable("aws-region", { ++ owner: "your-org", ++ repository: "your-repo", ++ name: "AWS_REGION", ++ value: region, ++ }); + }), +``` + +`role.roleArn` is an Output — Alchemy resolves it during deploy and +pipes it straight into the repo variable, so the ARN never needs to +round-trip through your shell. + +## Deploy the GitHub stack + +Run it once from your laptop, under the `admin` profile: + +```sh +alchemy deploy --config stacks/github.ts --profile admin +``` + +You'll see Alchemy plan the OIDC provider, the role, and the two repo +variables, then apply them. When it's done, head to your repo's +**Settings → Secrets and variables → Actions** page; you should see +`AWS_ROLE_ARN` and `AWS_REGION` under **Variables**. + +You only need to re-run this stack when you want to change the role's +permissions or trust policy. + +## Create the workflow + +Create `.github/workflows/deploy.yml`: + +```yaml +name: Deploy +on: + push: + branches: [main] + pull_request: + types: [opened, reopened, synchronize, closed] + +concurrency: + group: deploy-${{ github.ref }} + cancel-in-progress: false + +env: + STAGE: >- + ${{ github.event_name == 'pull_request' + && format('pr-{0}', github.event.number) + || (github.ref == 'refs/heads/main' && 'prod' || github.ref_name) }} + +jobs: + deploy: + if: github.event.action != 'closed' + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ vars.AWS_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + - name: Deploy + run: bun alchemy deploy --stage ${{ env.STAGE }} --yes + env: + PULL_REQUEST: ${{ github.event.number }} + GITHUB_SHA: ${{ github.sha }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + cleanup: + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install + - name: Safety Check + run: | + if [ "${{ env.STAGE }}" = "prod" ]; then + echo "ERROR: Cannot destroy prod environment in cleanup job" + exit 1 + fi + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ vars.AWS_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + - name: Destroy Preview + run: bun alchemy destroy --stage ${{ env.STAGE }} --yes + env: + PULL_REQUEST: ${{ github.event.number }} +``` + +The `configure-aws-credentials` step exchanges the workflow's OIDC +token for short-lived AWS credentials by assuming the role — the +`id-token: write` permission is what allows the exchange. Alchemy +picks the credentials up from the environment automatically (when +`CI=true`, it skips the interactive prompt and uses environment +variables). No secrets required. + +:::note[How STAGE is computed] + +- Pull requests → `pr-{number}` (e.g. `pr-42`) +- Push to `main` → `prod` +- Other branches → the branch name + +The cleanup job includes a safety check so `prod` can never be +accidentally destroyed — exactly the [stage isolation](/aws/tutorial/part-4) +from Part 4, driven by CI events. +::: + +## Add PR preview comments + +Post the live URL as a comment on every PR. Update `alchemy.run.ts`: + +```diff lang="typescript" +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; ++import * as GitHub from "alchemy/GitHub"; ++import * as Output from "alchemy/Output"; +import * as Effect from "effect/Effect"; ++import * as Layer from "effect/Layer"; +import Api from "./src/api.ts"; + +export default Alchemy.Stack( + "MyApp", + { +- providers: AWS.providers(), ++ providers: Layer.mergeAll( ++ AWS.providers(), ++ GitHub.providers(), ++ ), + state: AWS.state(), + }, + Effect.gen(function* () { + const api = yield* Api; + ++ if (process.env.PULL_REQUEST) { ++ yield* GitHub.Comment("preview-comment", { ++ owner: "your-org", ++ repository: "your-repo", ++ issueNumber: Number(process.env.PULL_REQUEST), ++ body: Output.interpolate` ++ ## Preview Deployed ++ ++ **URL:** ${api.functionUrl} ++ ++ Built from commit ${process.env.GITHUB_SHA?.slice(0, 7)} ++ ++ --- ++ _This comment updates automatically with each push._ ++ `, ++ }); ++ } + + return { + url: api.functionUrl, + }; + }), +); +``` + +The logical ID `"preview-comment"` stays the same across pushes, so +Alchemy updates the existing comment instead of creating a new one. +`GITHUB_TOKEN` is provided automatically by Actions and authorizes +the comment. + +## The full picture + +Here's how everything fits together: + +1. You ran `stacks/github.ts` once under `--profile admin`. That + created a GitHub OIDC provider and a repo-scoped IAM role, and + published the role ARN and region as repo variables. +2. A developer pushes a branch and opens a PR. +3. GitHub Actions checks out the code, exchanges its OIDC token for + short-lived AWS credentials, and runs + `alchemy deploy --stage pr-42`. +4. Alchemy creates an isolated copy of every resource (bucket, + function, etc.) under the `pr-42` stage and posts the preview URL + as a PR comment. +5. The reviewer clicks the URL and tests the preview. +6. The PR is merged — the cleanup job runs + `alchemy destroy --stage pr-42` and the preview infrastructure + disappears. +7. The push to `main` deploys `--stage prod`. + +Each environment is fully isolated with its own bucket, function, +and state. + +## Alternative: static access keys + +If OIDC isn't an option (some sandbox accounts disallow creating +identity providers), fall back to IAM access keys stored as repo +secrets and exposed to the deploy step as `AWS_ACCESS_KEY_ID` / +`AWS_SECRET_ACCESS_KEY` / `AWS_REGION` environment variables. The +[CI guide](/environments/ci#aws-with-access-keys) has the complete recipe, +including a `stacks/github.ts` that pushes the keys into the repo as +`GitHub.Secret`s. + +## Recap + +You've completed the tutorial. You now know how to: + +- **Part 1** — Create a Stack and deploy a resource +- **Part 2** — Add a Lambda Function with bindings to other + resources +- **Part 3** — Write integration tests against deployed stacks +- **Part 4** — Deploy isolated stages with `--stage` +- **Part 5** — Provision secretless CI credentials with + `AWS.IAM.OpenIDConnectProvider` + `AWS.IAM.Role`, publish them + with `GitHub.Variable`, and deploy from Actions + +## What's next + +- Go deeper on AWS — grow this app with + [DynamoDB](/aws/data/dynamodb), [SQS](/aws/messaging/sqs), + [Kinesis](/aws/messaging/kinesis), and event-driven wiring like + [S3 events](/aws/messaging/s3-events) and + [DynamoDB Streams](/aws/messaging/dynamodb-streams) +- Put an [API Gateway REST API](/aws/apis/api-gateway) with + stages and custom domains in front of your function +- Read [Choosing a runtime](/aws/compute/choosing-a-runtime) for when ECS, + EKS, or EC2 fits better than Lambda +- Explore the [CI guide](/environments/ci) for more workflow patterns, + including the Cloudflare equivalents +- Check out the [Testing a Stack](/testing/testing-a-stack) guide for + more advanced testing patterns +- Browse the [Providers](/providers) reference for all available + resources diff --git a/.agents/skills/alchemy/references/axiom/INDEX.md b/.agents/skills/alchemy/references/axiom/INDEX.md new file mode 100644 index 00000000..219c3665 --- /dev/null +++ b/.agents/skills/alchemy/references/axiom/INDEX.md @@ -0,0 +1,22 @@ +# axiom index + +6 pages. Observability as resources — OTEL datasets, ingest tokens, monitors, notifiers, and dashboards declared next to the code that emits the data. + +| Page | File | Covers | +| --- | --- | --- | +| Axiom | `_overview.md` | Observability as resources — OTEL datasets, ingest tokens, monitors, notifiers, and dashboards declared next to the code that emits the data. | +| Setup | `setup.md` | Connect alchemy to Axiom — account, credentials, and profiles. | + +## data/ + +| Page | File | Covers | +| --- | --- | --- | +| Datasets & ingest | `data/ingest.md` | Axiom datasets per OTel signal with OTLP endpoints as outputs, least-privilege ingest tokens, and per-stage naming with Stack.useSync. | + +## guides/ + +| Page | File | Covers | +| --- | --- | --- | +| Alerting: monitors & notifiers | `guides/alerting.md` | Turn APL queries into alerts — Threshold, MatchEvent, and AnomalyDetection monitors wired to Slack, email, PagerDuty, and webhook notifiers, all as resources in your Stack. | +| Deploy markers & annotations | `guides/annotations.md` | Overlay deploys, incidents, and flag flips as vertical markers on Axiom charts — point or range annotations, scoped to one or more datasets, declared as alchemy resources. | +| Dashboards as code | `guides/dashboards.md` | Declare Axiom dashboards — charts, layout, filter bars, saved views, and deploy annotations — as alchemy resources next to the services they observe. | diff --git a/.agents/skills/alchemy/references/axiom/_overview.md b/.agents/skills/alchemy/references/axiom/_overview.md new file mode 100644 index 00000000..69465807 --- /dev/null +++ b/.agents/skills/alchemy/references/axiom/_overview.md @@ -0,0 +1,101 @@ + + +# Axiom + +> Observability as resources — OTEL datasets, ingest tokens, monitors, notifiers, and dashboards declared next to the code that emits the data. + +Axiom is an event and observability store for logs, traces, and +metrics. Effect already emits OpenTelemetry, and with alchemy the +receiving end is code too: datasets, ingest tokens, monitors, +notifiers, and dashboards live in the same Stack as the Workers and +Functions they observe, so a threshold change is a reviewable diff. + +New here? [Set up credentials](/axiom/setup) first. + +## Resources + +A `Dataset` is the top-level container. Pick a `kind` per OTEL +signal — it exposes the OTLP endpoints as outputs: + +```typescript +const traces = yield* Axiom.Dataset("traces", { name: "app-traces", kind: "otel:traces:v1" }); +const logs = yield* Axiom.Dataset("logs", { name: "app-logs", kind: "otel:logs:v1" }); +const metrics = yield* Axiom.Dataset("metrics", { name: "app-metrics", kind: "otel:metrics:v1" }); +``` + +:::caution +Renaming a dataset or changing its `kind` is a **replacement** — the +old dataset and all its events are deleted. See +[Datasets & Ingest](/axiom/data/ingest) for details. +::: + +An `ApiToken` mints a least-privilege ingest credential for your +runtime to ship with: + +```typescript +const ingest = yield* Axiom.ApiToken("ingest", { + name: "prod-ingest", + datasetCapabilities: { + "app-traces": { ingest: ["create"] }, + }, +}); +``` + +`Monitor` + `Notifier` turn queries into alerts — see +[Monitors & Alerting](/axiom/guides/alerting) for threshold and +anomaly monitors: + +```typescript +const slack = yield* Axiom.Notifier("ops-slack", { + name: "ops-channel", + properties: { + slack: { slackUrl: process.env.SLACK_WEBHOOK_URL! }, + }, +}); + +yield* Axiom.Monitor("panics", { + name: "Service panic", + type: "MatchEvent", + aplQuery: `['app-logs'] | where message contains "panic:"`, + intervalMinutes: 1, + rangeMinutes: 1, + notifierIds: [slack.id], +}); +``` + +Beyond alerts, `Dashboard` declares charts as code, `View` saves an +APL query as a virtual dataset, and `Annotation` marks deploys on +your charts — see [Dashboards as Code](/axiom/guides/dashboards). +`VirtualField` derives computed fields on a dataset at query time. + +## Compose with your cloud + +Wire the dataset's OTLP endpoints and the ingest token into your +Worker or Lambda's environment, and Effect's OpenTelemetry output +flows into Axiom with no code changes — see +[Observability](/testing/observability) for the exporter-as-a-Layer +pattern: + +```typescript +vars: { + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: traces.otelTracesEndpoint, + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: logs.otelLogsEndpoint, +}, +``` + +For a full end-to-end walkthrough — dataset, token, Worker wiring, +and a monitor on top — follow +[Observability with Axiom](/cloudflare/observability/axiom-observability). + +## Reference + +[Dataset](/providers/axiom/dataset) · +[ApiToken](/providers/axiom/apitoken) · +[Monitor](/providers/axiom/monitor) · +[Notifier](/providers/axiom/notifier) · +[Dashboard](/providers/axiom/dashboard) · +[Annotation](/providers/axiom/annotation) · +[View](/providers/axiom/view) · +[VirtualField](/providers/axiom/virtualfield) diff --git a/.agents/skills/alchemy/references/axiom/data/ingest.md b/.agents/skills/alchemy/references/axiom/data/ingest.md new file mode 100644 index 00000000..8af9f76e --- /dev/null +++ b/.agents/skills/alchemy/references/axiom/data/ingest.md @@ -0,0 +1,181 @@ + + +# Datasets & ingest + +> Axiom datasets per OTel signal with OTLP endpoints as outputs, least-privilege ingest tokens, and per-stage naming with Stack.useSync. + +A `Dataset` is Axiom's top-level container — it stores your events, +logs, traces, or metrics. An `ApiToken` is the credential your +runtime ships with. Both are one-line resources, declared in the +same Stack as the Workers and Functions that emit the data. + +## Create a dataset + +```typescript +import * as Axiom from "alchemy/Axiom"; + +const logs = yield* Axiom.Dataset("logs", { + name: "my-app-logs", + kind: "otel:logs:v1", + description: "Application logs from prod workers", + retentionDays: 30, + useRetentionPeriod: true, +}); +``` + +`description`, `retentionDays`, and `useRetentionPeriod` update in +place, so a retention change is a reviewable diff. + +:::caution[Name and kind changes delete data] +`name` is the dataset's stable identifier in Axiom and `kind` is +fixed at creation — changing either triggers a **replacement**: the +old dataset is deleted along with everything ingested into it. Pick +both up front, and never rename a dataset that holds data you care +about. +::: + +## One dataset per signal + +`kind` determines the schema and how the data renders in the Axiom +UI. For OTel pipelines, create one dataset per signal: + +```typescript +const traces = yield* Axiom.Dataset("traces", { name: "app-traces", kind: "otel:traces:v1" }); +const logs = yield* Axiom.Dataset("logs", { name: "app-logs", kind: "otel:logs:v1" }); +const metrics = yield* Axiom.Dataset("metrics", { name: "app-metrics", kind: "otel:metrics:v1" }); +``` + +The default `kind` is `axiom:events:v1` — Axiom's general-purpose +event store — for anything that isn't OTLP. + +## OTLP endpoints as outputs + +Each dataset exposes Axiom's OTLP/HTTP endpoints as output +attributes, and alchemy runtimes have +[telemetry built in](/infrastructure-as-effects/telemetry) — wiring +an exporter is providing the `Axiom.Telemetry` binding layer on the +Function/Worker: + +```typescript +// src/observability.ts — declared at module scope so the Worker can import them +export const Traces = Axiom.Dataset("Traces", { name: "app-traces", kind: "otel:traces:v1" }); +export const Logs = Axiom.Dataset("Logs", { name: "app-logs", kind: "otel:logs:v1" }); +export const Ingest = Axiom.ApiToken("Ingest", { + name: "app-ingest", + datasetCapabilities: { + "app-traces": { ingest: ["create"] }, + "app-logs": { ingest: ["create"] }, + }, +}); + +// src/worker.ts — on the Worker's constructor Effect, merged with its other binding layers: +Effect.provide( + Layer.mergeAll( + Cloudflare.R2.ReadWriteBucketBinding, + Axiom.Telemetry({ token: Ingest, traces: Traces, logs: Logs }), + ), +) +``` + +Building the layer binds each dataset's endpoint and the ingest +token's `Authorization` header (as a secret) onto the host; at +runtime the built-in exporter ships each signal to its dataset, +flushed per request. See the +[end-to-end guide](/cloudflare/observability/axiom-observability) +for the full pattern. + +- `otelTracesEndpoint` / `otelLogsEndpoint` / `otelMetricsEndpoint` — + the per-signal OTLP/HTTP URLs. +- `otelEndpoint` — the root; most exporters auto-append the signal + path. +- `otelHeaders` — the non-secret headers Axiom expects, i.e. + `X-Axiom-Dataset: `. The `Authorization: Bearer` header is + deliberately excluded so no secret lives in dataset state — that's + what the ingest token below is for. + +## Mint an ingest token + +An `ApiToken` scoped with `datasetCapabilities` can ingest into +exactly the datasets you list and nothing else: + +```typescript +const ingest = yield* Axiom.ApiToken("ingest", { + name: "prod-ingest", + description: "OTEL collector ingest", + datasetCapabilities: { + "my-app-traces": { ingest: ["create"] }, + }, +}); +``` + +The same shape covers read-side credentials — for example a +query-only token for Grafana: + +```typescript +yield* Axiom.ApiToken("query", { + name: "grafana-reader", + datasetCapabilities: { + "my-app-traces": { query: ["read"] }, + "my-app-logs": { query: ["read"] }, + }, +}); +``` + +## Token rotation + +Axiom has no update endpoint for tokens, so capabilities are pinned +at creation: changing **any** prop replaces the token and mints a +new bearer value; identical props never rotate it. That makes +rotation a deploy — tweak the `description` (or any other field) +and the next deploy creates a fresh token, rewires every consumer, +and deletes the old one. + +:::caution[The bearer persists in alchemy state] +Axiom returns the raw token exactly once, at create time. Alchemy +captures it into `ingest.token` as a `Redacted` and persists +it in resource state — otherwise later deploys couldn't rewire it +into consumers. Treat your state store as sensitive: anyone with +read access can recover the bearer. Pass `ingest.token` to secret +sinks (a Worker env var becomes a `secret_text` binding; or a +[secrets store](/cloudflare/security/secrets-env)) rather than ever unwrapping it +yourself. +::: + +## Per-stage datasets + +Dataset names are global to your Axiom org, so give each stage its +own. `Stack.useSync` computes props from the resolved stage — handy +at module scope where there's no `yield*` available: + +```typescript +import * as Axiom from "alchemy/Axiom"; +import { Stack } from "alchemy/Stack"; + +export const Traces = Axiom.Dataset( + "Traces", + Stack.useSync(({ stage }) => ({ + name: `${stage}-traces`, + kind: "otel:traces:v1" as const, + description: `OTEL traces for stage '${stage}'`, + retentionDays: 30, + })), +); +``` + +Now `dev-sam` telemetry never lands in the `prod` dataset, and +tearing down a preview stage deletes its data with it. See +[Stack](/infrastructure-as-code/stack) for the full `useSync` semantics. + +## Where next + +- [Ship Worker telemetry to Axiom](/cloudflare/observability/axiom-observability) — + the end-to-end guide: datasets, token, env wiring, and the + zero-exporter Cloudflare push path. +- [Alerting](/axiom/guides/alerting) — monitors and notifiers over + the data you just ingested. +- [Telemetry](/infrastructure-as-effects/telemetry) — the built-in + OTel exporters and how they flush per request. +- Reference: [Dataset](/providers/axiom/dataset) · + [ApiToken](/providers/axiom/apitoken) diff --git a/.agents/skills/alchemy/references/axiom/guides/alerting.md b/.agents/skills/alchemy/references/axiom/guides/alerting.md new file mode 100644 index 00000000..e8716be9 --- /dev/null +++ b/.agents/skills/alchemy/references/axiom/guides/alerting.md @@ -0,0 +1,208 @@ + + +# Alerting: monitors & notifiers + +> Turn APL queries into alerts — Threshold, MatchEvent, and AnomalyDetection monitors wired to Slack, email, PagerDuty, and webhook notifiers, all as resources in your Stack. + +An Axiom `Monitor` is a saved APL query that runs on a fixed cadence +and fires when its condition is met. A `Notifier` is the destination +it fires to — Slack, email, PagerDuty, a webhook. In alchemy both are +resources in your Stack, so a threshold change or a new on-call +channel is a reviewable diff, not a console edit. + +Everything below runs inside your Stack's `Effect.gen` — see +[Setup](/axiom/setup) for credentials and the +[Axiom overview](/axiom) for datasets like `app-traces` and +`app-logs` used in the queries. + +## Create a notifier + +```typescript +import * as Axiom from "alchemy/Axiom"; + +const slack = yield* Axiom.Notifier("ops-slack", { + name: "ops-channel", + properties: { + slack: { slackUrl: process.env.SLACK_WEBHOOK_URL! }, + }, +}); +``` + +Exactly one channel goes under `properties` — here `slack` with an +incoming webhook URL. The resource's `id` output is what monitors +target. + +## Threshold: alert when a value crosses a line + +```typescript +yield* Axiom.Monitor("error-rate", { + name: "High error rate", + description: "Fires when error count exceeds 100/5m", + type: "Threshold", + aplQuery: ` + ['app-traces'] + | where status >= 500 + | summarize count() by bin_auto(_time) + `, + operator: "Above", + threshold: 100, + intervalMinutes: 5, + rangeMinutes: 5, + alertOnNoData: false, + resolvable: true, + notifierIds: [slack.id], +}); +``` + +Every `intervalMinutes`, the monitor runs `aplQuery` over the last +`rangeMinutes` and compares the aggregate against `threshold` using +`operator` (`Above`, `AboveOrEqual`, `Below`, `BelowOrEqual`, or +`AboveOrBelow`). `resolvable: true` sends a resolution notice when +the value drops back under the line; `alertOnNoData` controls whether +an empty result counts as a problem. + +`notifierIds: [slack.id]` is the wiring: `slack.id` is an output +attribute of the notifier flowing into the monitor's inputs, so +alchemy deploys the notifier first and fills in the real id. + +## MatchEvent: alert on every matching event + +```typescript +yield* Axiom.Monitor("panics", { + name: "Service panic", + type: "MatchEvent", + aplQuery: `['app-logs'] | where message contains "panic:"`, + intervalMinutes: 1, + rangeMinutes: 1, + notifierIds: [slack.id], +}); +``` + +A `MatchEvent` monitor has no threshold — it fires for every event +the query matches. Use it for rare, individually-actionable events +like panics or failed deploys, where one occurrence is already worth +a page. + +## AnomalyDetection: alert on deviation from baseline + +```typescript +yield* Axiom.Monitor("traffic-anomaly", { + name: "Traffic anomaly", + type: "AnomalyDetection", + aplQuery: `['app-traces'] | summarize count() by bin_auto(_time)`, + compareDays: 7, + tolerance: 25, // % + intervalMinutes: 15, + rangeMinutes: 15, + notifierIds: [slack.id], +}); +``` + +Instead of a static threshold, the monitor learns a baseline from the +same window over the previous `compareDays` days and fires when the +current result deviates by more than `tolerance` percent. Good for +"traffic looks wrong" signals where the right absolute number changes +by hour and weekday. + +Changing a monitor's `type` triggers a replacement; every other prop +updates in place. + +## Fan out to multiple notifiers + +```typescript +const pagerduty = yield* Axiom.Notifier("pagerduty", { + name: "primary-oncall", + properties: { + pagerduty: { routingKey: process.env.PAGERDUTY_ROUTING_KEY!, token: "" }, + }, +}); + +yield* Axiom.Monitor("error-rate", { + // ...same props as above + notifierIds: [slack.id, pagerduty.id], +}); +``` + +`notifierIds` takes any number of ids, so one monitor can post to the +team channel and page on-call at the same time. Notifier channels +cover Slack, email (`email: { emails: [...] }`), PagerDuty, Opsgenie, +Discord, Microsoft Teams, and generic webhooks. + +## Custom webhook with a templated body + +```typescript +yield* Axiom.Notifier("incident-webhook", { + name: "incident.io", + properties: { + customWebhook: { + url: "https://api.incident.io/v2/alert_events", + headers: { "Content-Type": "application/json" }, + secretHeaders: { Authorization: `Bearer ${process.env.INCIDENT_TOKEN}` }, + body: '{"title": "{{.Monitor.Name}}", "status": "firing"}', + }, + }, +}); +``` + +For anything without a built-in channel, `customWebhook` gives you +full control over the request: static `headers`, `secretHeaders` for +credentials, and a `body` template with access to monitor fields like +`{{.Monitor.Name}}`. + +## Derived alert fields with VirtualField + +```typescript +yield* Axiom.VirtualField("status-class", { + dataset: "app-traces", + name: "status_class", + description: "HTTP response class bucket", + expression: 'strcat(tostring(toint(status / 100)), "xx")', + type: "string", +}); +``` + +A `VirtualField` is a saved APL expression that appears as a derived +column on its `dataset` at query time. Define common computations — +status classes, latency buckets, parsed JSON paths — once, instead of +repeating the expression in every monitor and dashboard. A virtual +field is bound to one `dataset`; changing the dataset triggers a +replacement. + +Monitors on that dataset can then query the derived column directly: + +```typescript +yield* Axiom.Monitor("5xx-rate", { + name: "5xx rate", + type: "Threshold", + aplQuery: ` + ['app-traces'] + | where status_class == "5xx" + | summarize count() by bin_auto(_time) + `, + operator: "Above", + threshold: 50, + intervalMinutes: 5, + rangeMinutes: 5, + notifierIds: [slack.id], +}); +``` + +If the definition of "5xx" ever changes, you update the virtual +field's `expression` — every monitor and dashboard built on +`status_class` follows. + +## Where next + +- [Ingest](/axiom/data/ingest) — datasets, ingest tokens, and wiring OTLP + endpoints into your Workers and Functions. +- [Dashboards](/axiom/guides/dashboards) — chart the same queries + your monitors run. +- [Axiom overview](/axiom) — all Axiom resources at a glance. + +Reference: + +- [Monitor API reference](/providers/axiom/monitor) +- [Notifier API reference](/providers/axiom/notifier) +- [VirtualField API reference](/providers/axiom/virtualfield) diff --git a/.agents/skills/alchemy/references/axiom/guides/annotations.md b/.agents/skills/alchemy/references/axiom/guides/annotations.md new file mode 100644 index 00000000..f0b20203 --- /dev/null +++ b/.agents/skills/alchemy/references/axiom/guides/annotations.md @@ -0,0 +1,110 @@ + + +# Deploy markers & annotations + +> Overlay deploys, incidents, and flag flips as vertical markers on Axiom charts — point or range annotations, scoped to one or more datasets, declared as alchemy resources. + +An Axiom `Annotation` is a vertical marker overlaid on every chart +that queries the datasets it names — a deploy, an incident, a +feature-flag flip, correlated with your telemetry at a glance. In +alchemy it's one resource: declare it next to the services it +annotates, and each stage gets its own markers. + +You'll need the Axiom provider registered ([setup](/axiom/setup)) +and a dataset to annotate ([ingest](/axiom/data/ingest)). + +## Drop a point marker + +```typescript +import * as Axiom from "alchemy/Axiom"; + +yield* Axiom.Annotation("release-1.2.3", { + type: "deploy", + title: "Release 1.2.3", + datasets: ["my-app-traces"], + time: "2026-06-30T14:00:00Z", + url: "https://github.com/acme/app/releases/tag/v1.2.3", +}); +``` + +`type` and `datasets` are the only required props. `type` is a +free-form string (`"deploy"`, `"incident"`, …) that groups markers +visually in the UI; `title`, `description`, and `url` decorate the +marker's tooltip. `time` is optional — omit it and Axiom stamps the +annotation with its creation time. Axiom assigns the annotation `id` +server-side and exposes it as an output. + +## Mark a time range + +```typescript +yield* Axiom.Annotation("inc-2026-04-27", { + type: "incident", + title: "Database failover", + datasets: ["my-app-traces"], + time: "2026-04-27T18:05:00Z", + endTime: "2026-04-27T18:32:00Z", + url: "https://incident.io/incidents/abc123", +}); +``` + +Adding `endTime` turns the vertical line into a shaded band — an +incident window, a maintenance period, a load test. Annotations for +past events like this are a natural fit for resources: they stay in +your program, so every stage (and every rebuild of a stage) carries +the same incident history. + +## Scope across datasets + +```typescript +yield* Axiom.Annotation("release-1.2.3", { + type: "deploy", + title: "Release 1.2.3", + datasets: ["my-app-traces", "my-app-logs"], + time: "2026-06-30T14:00:00Z", +}); +``` + +`datasets` takes any number of dataset names, and the marker renders +on charts over each of them. One annotation across your traces and +logs datasets keeps the deploy line in sync on both dashboards — +declare it once rather than duplicating per dataset. + +## Mark each deploy + +```typescript +import pkg from "./package.json" with { type: "json" }; + +yield* Axiom.Annotation(`deploy-${pkg.version}`, { + type: "deploy", + title: `Release ${pkg.version}`, + datasets: ["my-app-traces"], + time: new Date().toISOString(), + url: `https://github.com/acme/app/releases/tag/v${pkg.version}`, +}); +``` + +Key the logical ID by version: bumping the version creates a fresh +marker at deploy time, while re-deploying the same version reconciles +the existing annotation in place instead of piling up duplicates — +the provider probes Axiom with the server-assigned `id` and only +creates when nothing is found. + +:::note +Like any resource, an annotation whose logical ID leaves the program +is deleted on the next deploy — so the version-keyed marker above +tracks the *current* release per stage, not the full release +history. For a permanent trail of every past deploy, create +annotations out-of-band at release time (Axiom's API from CI); keep +the resource form for markers you want reproducible per stage. +::: + +## Where next + +- [Dashboards as code](/axiom/guides/dashboards) — the charts your + markers land on, plus saved views. +- [Ingest](/axiom/data/ingest) — datasets, tokens, and wiring OTEL output + into Axiom. +- [Annotation API reference](/providers/axiom/annotation) — full + props and attributes. diff --git a/.agents/skills/alchemy/references/axiom/guides/dashboards.md b/.agents/skills/alchemy/references/axiom/guides/dashboards.md new file mode 100644 index 00000000..93c96e95 --- /dev/null +++ b/.agents/skills/alchemy/references/axiom/guides/dashboards.md @@ -0,0 +1,243 @@ + + +# Dashboards as code + +> Declare Axiom dashboards — charts, layout, filter bars, saved views, and deploy annotations — as alchemy resources next to the services they observe. + +An Axiom dashboard is a single JSON document: charts, a grid layout, +a time window, a refresh interval. In alchemy that document is the +input to one `Dashboard` resource, so a chart tweak is a reviewable +diff and every stage gets the same dashboard. + +This guide builds an ops dashboard chart by chart, then adds the two +resources that round it out: saved `View`s and deploy `Annotation`s. +You'll need the Axiom provider registered ([setup](/axiom/setup)) and +a dataset to query ([ingest](/axiom/data/ingest)). + +## Start with an empty dashboard + +```typescript +import * as Axiom from "alchemy/Axiom"; + +yield* Axiom.Dashboard("ops", { + dashboard: { + name: "Ops Overview", + owner: "", // org-shared — required for API tokens + description: "Top-level service health", + charts: [], + layout: [], + refreshTime: 60, // seconds: 15 | 60 | 300 + schemaVersion: 2, + timeWindowStart: "qr-now-1h", + timeWindowEnd: "qr-now", + }, +}); +``` + +The resource takes the full dashboard document at `schemaVersion: 2`. +Axiom assigns the `uid` on create (exposed as an output along with +`id` and the timestamps); updates overwrite the whole document in +place, so the deployed dashboard always matches what's in your code. + +## Add a chart + +```typescript +import type { Chart, LayoutCell } from "alchemy/Axiom"; + +const errors: Chart = { + id: "errors-5m", + name: "5xx errors / 5m", + type: "TimeSeries", + query: { + apl: `['my-app-traces'] + | where status >= 500 + | summarize count() by bin_auto(_time)`, + }, +}; + +yield* Axiom.Dashboard("errors", { + dashboard: { + name: "Errors", + owner: "", + refreshTime: 60, + schemaVersion: 2, + timeWindowStart: "qr-now-24h", + timeWindowEnd: "qr-now", + charts: [errors], + layout: [{ i: errors.id, x: 0, y: 0, w: 12, h: 6 } satisfies LayoutCell], + }, +}); +``` + +`chart.id` is a free-form string you pick — the layout cell's `i` +joins to it. There's no `dataset` field: the dataset is implicit in +the APL query (`['my-app-traces']`). + +## The chart union + +```typescript +const p99: Chart = { + id: "p99", + name: "p99 latency", + type: "Statistic", + query: { + apl: `['my-app-traces'] | summarize percentile(duration_ms, 99)`, + }, +}; +``` + +`Chart` is a discriminated union over the kinds Axiom's API +validates: `TimeSeries`, `Table`, `Pie`, `Statistic`, `Heatmap`, and +`LogStream` for data charts, plus `Note` and `SmartFilter` (below). +Every data chart carries exactly the same payload — `{ id, name, +type, query: { apl } }` — and switching kinds is just changing +`type`. + +## Add a note + +```typescript +const readme: Chart = { + id: "readme", + type: "Note", + text: "## Ops overview\n\nEscalation: #ops-oncall. Runbooks linked per chart.", +}; +``` + +`Note` is the one chart without a `query` or a `name` — its payload +is strictly `{ id, type, text, variant? }`, with `text` rendered as +markdown in the cell. + +## Add a filter bar + +```typescript +const filters: Chart = { + id: "env-filter", + type: "SmartFilter", + filters: [ + { + id: "environment", + type: "select", + name: "Environment", + selectType: "list", + options: [ + { key: "production", value: "production", default: true }, + { key: "staging", value: "staging" }, + ], + }, + ], +}; +``` + +`SmartFilter` is the wire name for what the UI calls a filter bar. +Each entry is either a free-text `search` filter or a dropdown +`select` filter — dropdown options come from an inline `list` (as +here) or, with `selectType: "apl"`, from a query returning +`{ key, value }` rows. Other charts pick the value up by declaring a +query parameter named after the filter's `id`: +`declare query_parameters (environment:string = "")`. + +## Lay out the grid + +```typescript +layout: [ + { i: "env-filter", x: 0, y: 0, w: 12, h: 2 }, + { i: "errors-5m", x: 0, y: 2, w: 6, h: 6 }, + { i: "p99", x: 6, y: 2, w: 6, h: 6 }, +], +``` + +Each `LayoutCell` positions one chart by its `i`: `x`/`y` place it on +the grid, `w`/`h` size it, and optional `minW`/`minH`/`maxW`/`maxH` +and `static` constrain how it can be resized or moved in the UI. +Every chart in `charts` needs a matching cell. + +## Compare against yesterday + +```typescript +yield* Axiom.Dashboard("compare", { + dashboard: { + name: "Compare vs yesterday", + owner: "", + refreshTime: 300, + schemaVersion: 2, + timeWindowStart: "qr-now-1h", + timeWindowEnd: "qr-now", + against: "-1d", // overlay the same window from 24h ago + charts: [], + layout: [], + }, +}); +``` + +`against` overlays each chart with the same query shifted by the +given offset — the classic "is this spike new or does it happen every +day at this hour" view. + +## Payload gotchas + +Axiom's `POST /v2/dashboards` is strict, and the failure modes are +worth knowing up front: + +- Relative time windows use the `qr-now-{duration}` form + (`"qr-now-7d"`, `"qr-now"`) — plain `"now-7d"` is rejected. +- When authenticating with an API token, `dashboard.owner` must be + `""`. Axiom rewrites it to the org-shared `X-AXIOM-EVERYONE`; + per-user "private" dashboards aren't allowed for tokens. +- Data-chart payloads accept only `id`, `name`, `type`, `query` — + extra keys like `dataset` or `description` fail with + `Unrecognized keys: ""`. +- `refreshTime` is in seconds: `15`, `60`, or `300`. + +## Save a view + +```typescript +yield* Axiom.View("recent-errors", { + name: "recent-errors", + description: "Last 100 5xx responses", + datasets: ["my-app-traces"], + aplQuery: ` + ['my-app-traces'] + | where status >= 500 + | order by _time desc + | take 100 + `, +}); +``` + +A `View` is a named, shareable APL query — the investigation you keep +re-typing, pinned as a resource. Its `name` is the identifier, so +renaming a view is a replacement (delete old, create new). + +## Mark deploys with annotations + +```typescript +yield* Axiom.Annotation("deploy-1.2.3", { + type: "deploy", + title: "Release 1.2.3", + datasets: ["my-app-traces", "my-app-logs"], + time: new Date().toISOString(), + url: "https://github.com/acme/app/releases/tag/v1.2.3", +}); +``` + +An `Annotation` draws a vertical marker on every chart over the +listed datasets — pass a single `time` for a point event or `time` + +`endTime` for a range (an incident window), with `type` grouping +markers visually. Deploy markers next to your error charts answer +"did the release cause this" at a glance. + +## Where next + +- [Alerting](/axiom/guides/alerting) — turn the same APL queries into + monitors and notifiers. +- [Ingest](/axiom/data/ingest) — datasets, tokens, and wiring OTEL output + into Axiom. +- [Axiom overview](/axiom) — all resources at a glance. + +Reference: + +- [Dashboard API reference](/providers/axiom/dashboard) +- [View API reference](/providers/axiom/view) +- [Annotation API reference](/providers/axiom/annotation) diff --git a/.agents/skills/alchemy/references/axiom/setup.md b/.agents/skills/alchemy/references/axiom/setup.md new file mode 100644 index 00000000..cdf01687 --- /dev/null +++ b/.agents/skills/alchemy/references/axiom/setup.md @@ -0,0 +1,32 @@ + + +# Setup + +> Connect alchemy to Axiom — account, credentials, and profiles. + +Sign up at [axiom.co](https://axiom.co) and create an API token in +the [Axiom app](https://app.axiom.co). Register the provider next to +your cloud's: + +```typescript +// alchemy.run.ts +import * as Axiom from "alchemy/Axiom"; + +providers: Layer.mergeAll(Cloudflare.providers(), Axiom.providers()), +``` + +Run `alchemy profile edit --add Axiom` and choose an API token or personal +access token. It is entered interactively and saved under +`~/.alchemy/credentials//`. + +In CI (`CI=true`), profiles are bypassed. Set `AXIOM_TOKEN` or +`AXIOM_API_KEY`; `AXIOM_ORG_ID` and `AXIOM_URL` are optional. + +See [Profiles](/environments/profiles) for how credentials are stored +and switched. + +## Next steps + +- [Axiom overview](/axiom) — resources and compositions. diff --git a/.agents/skills/alchemy/references/better-auth/INDEX.md b/.agents/skills/alchemy/references/better-auth/INDEX.md new file mode 100644 index 00000000..de43a435 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/INDEX.md @@ -0,0 +1,59 @@ +# better-auth index + +28 pages. Build authentication with an Effect service, automatic database bindings, and deploy-time migrations. + +| Page | File | Covers | +| --- | --- | --- | +| Better Auth | `_overview.md` | Build authentication with an Effect service, automatic database bindings, and deploy-time migrations. | + +## databases/ + +| Page | File | Covers | +| --- | --- | --- | +| Choose a database | `databases/_overview.md` | Choose a Better Auth database layer with a complete, standalone integration example. | +| Aurora Data API | `databases/aurora-data-api.md` | Deploy a Better Auth Lambda with Aurora PostgreSQL over HTTPS and automatic IAM bindings. | +| Cloudflare D1 | `databases/cloudflare-d1.md` | Deploy a Better Auth Worker with a native D1 database and automatic schema migrations. | +| Cloudflare Hyperdrive | `databases/cloudflare-hyperdrive.md` | Deploy a Better Auth Worker with an uncached Hyperdrive connection to a Neon Postgres origin. | +| Drizzle | `databases/drizzle.md` | Generate a Relations v2 auth schema and deploy a Better Auth Lambda with a request-scoped raw Drizzle database. | +| Memory | `databases/memory.md` | Run a disposable Better Auth Worker with an isolate-local memory store. | +| MySQL | `databases/mysql.md` | Deploy Better Auth on Lambda with an existing MySQL database and a request-scoped mysql2 pool. | +| Neon | `databases/neon.md` | Deploy a Better Auth Worker with a Neon project and the serverless database driver. | +| Postgres | `databases/postgres.md` | Deploy Better Auth on Lambda with an existing PostgreSQL database and a request-scoped pg pool. | +| SQLite | `databases/sqlite.md` | Run Better Auth in a local Bun server with a persistent SQLite file. | + +## guides/ + +| Page | File | Covers | +| --- | --- | --- | +| Config and secrets | `guides/configuration.md` | Bind Effect Config into an Alchemy host, compose database layers, and preserve generated signing secrets. | +| HTTP API middleware | `guides/http-api-middleware.md` | Authenticate each request and provide a typed current user to Effect HTTP API handlers. | +| Migrations | `guides/migrations.md` | Apply Better Auth schema changes at deploy, or manage the schema yourself. | +| Secondary storage | `guides/secondary-storage.md` | Supply a strongly consistent session and rate-limit store through an Effect layer. | + +## sign-in-providers/ + +| Page | File | Covers | +| --- | --- | --- | +| Sign-in providers | `sign-in-providers/_overview.md` | Choose a standalone sign-in walkthrough for your existing Alchemy project. | +| Custom OAuth and OIDC | `sign-in-providers/custom-oauth.md` | Add Keycloak OIDC sign-in to an Alchemy Worker with D1, one small step at a time. | +| Email and password | `sign-in-providers/email-password.md` | Add D1-backed registration, real email verification, and password recovery to an Alchemy project. | +| GitHub | `sign-in-providers/github.md` | Add GitHub sign-in to a D1-backed Alchemy Worker, one step at a time. | +| Google | `sign-in-providers/google.md` | Add Google sign-in to a D1-backed Alchemy Worker, one step at a time. | +| Microsoft | `sign-in-providers/microsoft.md` | Add Microsoft sign-in to an Alchemy Worker with D1, one small step at a time. | + +## tutorial/ + +| Page | File | Covers | +| --- | --- | --- | +| Part 1: Create an Auth service | `tutorial/part-1.md` | Install Better Auth, provide a D1 database layer, and create an Effect service. | +| Part 2: Mount the HTTP API | `tutorial/part-2.md` | Serve Better Auth routes and an Effect HTTP API from the same Worker. | +| Part 3: Build sign-in | `tutorial/part-3.md` | Add a same-origin browser client with sign-up, sign-in, session lookup, and sign-out. | +| Part 4: Protect API endpoints | `tutorial/part-4.md` | Resolve the session in Effect HTTP API middleware and provide a typed CurrentUser service. | +| Part 5: Add GitHub sign-in | `tutorial/part-5.md` | Register a GitHub OAuth app, bind its credentials through Effect Config, and sign in from the browser. | +| Part 6: Deploy and verify | `tutorial/part-6.md` | Deploy the authenticated API, configure production OAuth callbacks, and verify session boundaries. | + +## upgrades/ + +| Page | File | Covers | +| --- | --- | --- | +| Upgrading from 1.6 to 1.7.5 or newer | `upgrades/from-1-6-to-1-7.md` | Upgrade Better Auth directly from 1.6, preserve existing users, and update your schema and application APIs. | diff --git a/.agents/skills/alchemy/references/better-auth/_overview.md b/.agents/skills/alchemy/references/better-auth/_overview.md new file mode 100644 index 00000000..1a00d762 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/_overview.md @@ -0,0 +1,49 @@ + + +# Better Auth + +> Build authentication with an Effect service, automatic database bindings, and deploy-time migrations. + +Build sign-in and protected APIs with [Better Auth](https://better-auth.com), using ordinary Effect services and layers. Alchemy supplies database bindings, a stable signing secret, and supported schema migrations. + +## Start the tutorial + +```typescript +import { BetterAuth } from "@alchemy.run/better-auth"; + +const makeAuth = BetterAuth({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, +}); +``` + +The [six-part tutorial](/better-auth/tutorial/part-1) turns this into a running application with browser sign-in, session middleware, and GitHub authentication. + +| Part | Build | +| --- | --- | +| [1. Create an Auth service](/better-auth/tutorial/part-1) | Install the module and provide a database layer | +| [2. Mount the HTTP API](/better-auth/tutorial/part-2) | Serve authentication and application routes | +| [3. Build sign-in](/better-auth/tutorial/part-3) | Sign up, sign in, read sessions, and sign out | +| [4. Protect API endpoints](/better-auth/tutorial/part-4) | Provide the current user through Effect middleware | +| [5. Add GitHub sign-in](/better-auth/tutorial/part-5) | Register an OAuth app and bind its credentials | +| [6. Deploy and verify](/better-auth/tutorial/part-6) | Configure production callbacks and check persistence | + +## Choose a sign-in provider + +Use [email/password](/better-auth/sign-in-providers/email-password), [GitHub](/better-auth/sign-in-providers/github), [Google](/better-auth/sign-in-providers/google), [Microsoft](/better-auth/sign-in-providers/microsoft), or [custom OAuth](/better-auth/sign-in-providers/custom-oauth). Each guide covers registration, configuration, and browser sign-in. + +## Choose a database + +Keep the Auth service and change the database layer. The [database guides](/better-auth/databases) cover D1, Hyperdrive, Neon, Aurora, Postgres, MySQL, Drizzle, SQLite, and memory. + +## Extend the application + +Use [Config and secrets](/better-auth/guides/configuration) for deployment bindings, [HTTP API middleware](/better-auth/guides/http-api-middleware) for Effect handlers, and [secondary storage](/better-auth/guides/secondary-storage) for custom storage layers. + +For Better Auth's authentication features and options, see the [Better Auth documentation](https://better-auth.com/docs). + +## Upgrade an existing installation + +The [1.6 → 1.7.5 upgrade guide](/better-auth/upgrades/from-1-6-to-1-7) separates package and API changes from manual data preparation. [Migrations](/better-auth/guides/migrations) explains what deployment applies automatically. diff --git a/.agents/skills/alchemy/references/better-auth/databases/_overview.md b/.agents/skills/alchemy/references/better-auth/databases/_overview.md new file mode 100644 index 00000000..bf8be9dc --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/databases/_overview.md @@ -0,0 +1,49 @@ + + +# Choose a database + +> Choose a Better Auth database layer with a complete, standalone integration example. + +Each database page includes its own auth configuration, database wiring, and host setup. You can start with any page without copying files from the tutorial. + +## Worker databases + +[Cloudflare D1](/better-auth/databases/cloudflare-d1) uses a native Worker binding and migrates through D1's API. It also supports local development, but not plugins requiring interactive transactions. + +[Hyperdrive](/better-auth/databases/cloudflare-hyperdrive) provides pooled access to an existing Postgres or MySQL origin. Auth connections must disable caching, and automatic migrations require a separate origin URL. + +[Neon](/better-auth/databases/neon) connects over WebSockets from Workers or Lambda. Its example provisions the database and uses the same connection URI for automatic migrations. + +## Lambda and TCP databases + +[Aurora Data API](/better-auth/databases/aurora-data-api) connects Lambda to Aurora PostgreSQL over HTTPS with IAM bindings. The example creates the database network and cluster without putting the Lambda in that VPC. + +[Postgres](/better-auth/databases/postgres) and [MySQL](/better-auth/databases/mysql) connect to existing databases through request-scoped driver pools. Both examples include a Lambda host, driver installation, and optional separate migration credentials. + +## Application-owned schemas + +[Drizzle](/better-auth/databases/drizzle) uses the Relations v2 adapter with a raw Drizzle database. The page includes CLI configuration, schema generation, migration commands, and a Lambda host; your application owns the migration history. + +## Local and disposable storage + +[SQLite](/better-auth/databases/sqlite) uses Bun's built-in SQLite driver and a local file. It is not compatible with a Worker, including one running under `alchemy dev`, or a standard Node.js Lambda. + +[Memory](/better-auth/databases/memory) needs no optional driver and retains records only within one process or isolate. Use it for tests and disposable development, not production authentication. + +## Provisioning and connections + +`Cloudflare.D1.Database`, `Neon.Project`, and `AWS.RDS.Aurora` create infrastructure. Better Auth's database layers connect to it; providing an existing URL does not create the database, credentials, or network access. + +Each page shows its required peer installations. Resolve external credentials during host construction so Alchemy binds them into the host, rather than discovering them only inside a request. + +## Runtime lifetime + +Hosts construct their layers once, while Better Auth acquires its database on the first auth operation in each request or invocation. Disposable pools close when that execution settles; do not open Worker sockets or pools at module scope. + +## Secrets and schema changes + +In an Alchemy host, omitting `secret` provisions and binds a stable signing secret automatically. Preserve it when migrating an existing application, and protect the stack state that stores it. + +Supported layers run additive migrations during deployment, before updating the host; they do not move records between databases. See [migrations](/better-auth/guides/migrations), [configuration](/better-auth/guides/configuration), and the [1.6 to 1.7 upgrade guide](/better-auth/upgrades/from-1-6-to-1-7) before changing an existing application. diff --git a/.agents/skills/alchemy/references/better-auth/databases/aurora-data-api.md b/.agents/skills/alchemy/references/better-auth/databases/aurora-data-api.md new file mode 100644 index 00000000..8435b728 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/databases/aurora-data-api.md @@ -0,0 +1,120 @@ + + +# Aurora Data API + +> Deploy a Better Auth Lambda with Aurora PostgreSQL over HTTPS and automatic IAM bindings. + +This example creates Aurora PostgreSQL and serves Better Auth through a Lambda Function URL. Aurora needs a VPC, but Lambda reaches the Data API over HTTPS without joining that VPC. + +## Install + +```sh +bun add @alchemy.run/better-auth better-auth@^1.7.5 kysely @distilled.cloud/aws +``` + +Configure AWS through an [Alchemy profile](/environments/profiles) in a region supporting Aurora PostgreSQL's Data API. No `pg` driver or TCP connection pool is required. + +## Define the database and network + +```typescript title="src/database.ts" +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; + +export const AuthDb = Effect.gen(function* () { + const network = yield* AWS.EC2.Network("AuthNetwork", { + cidrBlock: "10.42.0.0/16", + availabilityZones: 2, + }); + const securityGroup = yield* AWS.EC2.SecurityGroup("AuthSecurityGroup", { + vpcId: network.vpcId, + description: "Aurora auth database", + }); + return yield* AWS.RDS.Aurora("AuthDb", { + databaseName: "auth", + subnetIds: network.privateSubnetIds, + securityGroupIds: [securityGroup.groupId], + dataApi: true, + }); +}); +``` + +The Aurora composite creates the cluster, writer, and database-credentials secret. The security group needs no inbound Postgres rule for Data API access. + +## Define the auth Lambda + +```typescript title="src/function.ts" +import { BetterAuth } from "@alchemy.run/better-auth"; +import { AuroraDataApi } from "@alchemy.run/better-auth/AuroraDataApi"; +import * as AWS from "alchemy/AWS"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import { AuthDb } from "./database.ts"; + +export default class AuthApi extends AWS.Lambda.Function()( + "AuthApi", + { + main: import.meta.url, + functionUrl: true, + memorySize: 512, + timeout: Duration.seconds(30), + }, + Effect.gen(function* () { + const auth = yield* BetterAuth({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, + }); + return { fetch: auth.fetch }; + }).pipe(Effect.provide(AuroraDataApi(AuthDb, { database: "auth" }))), +) {} +``` + +The database layer binds cluster and secret identifiers and grants the function Data API and Secrets Manager permissions. The public Function URL serves auth endpoints under `/api/auth`. + +## Define the stack + +```typescript title="alchemy.run.ts" +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; +import AuthApi from "./src/function.ts"; + +export default Alchemy.Stack( + "BetterAuthAurora", + { + providers: AWS.providers(), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const api = yield* AuthApi; + return { url: api.functionUrl }; + }), +); +``` + +The auth signing secret is generated and bound automatically, separately from Aurora's database-credentials secret. Preserve and protect local `.alchemy` state across deployments. + +## Deploy and migrate + +```sh +bunx alchemy deploy +``` + +Automatic migrations wait for the writer and use the Data API with deploy-time AWS credentials. Those credentials need database and secret access too; the function's IAM bindings do not grant permissions to the deployer. + +Set `AUTH_ORIGIN` to the printed Function URL and verify the endpoint: + +```sh +curl -i "$AUTH_ORIGIN/api/auth/get-session" +``` + +## Cluster and migration options + +For a bare `AWS.RDS.DBCluster` resource, provide an `AWS.SecretsManager.Secret` resource through the layer's `secret` option. Passing the Aurora composite, as above, also supplies the writer dependency needed to order migrations. + +The layer retries transient errors while a Serverless v2 cluster resumes, with bounded retries at deployment and runtime. SQL runs within each invocation without retaining a TCP connection across invocations. + +The layer's `migrate: false` option disables automatic migration support; Better Auth's own `migrate: false` also opts out of its migration Action. Automatic migrations are additive and do not move records between databases. + +See [configuration](/better-auth/guides/configuration), [migrations](/better-auth/guides/migrations), the [upgrade guide](/better-auth/upgrades/from-1-6-to-1-7), and the [`AuroraDataApi` reference](/providers/betterauth/auroradataapi). diff --git a/.agents/skills/alchemy/references/better-auth/databases/cloudflare-d1.md b/.agents/skills/alchemy/references/better-auth/databases/cloudflare-d1.md new file mode 100644 index 00000000..7b9bf106 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/databases/cloudflare-d1.md @@ -0,0 +1,96 @@ + + +# Cloudflare D1 + +> Deploy a Better Auth Worker with a native D1 database and automatic schema migrations. + +This example creates a D1 database and a Worker serving Better Auth at `/api/auth`. It includes the auth configuration, database layer, and stack; no tutorial files are required. + +## Install + +```sh +bun add @alchemy.run/better-auth better-auth@^1.7.5 kysely +``` + +`kysely` is the SQL adapter peer; D1 needs no separate database driver. Configure Cloudflare credentials through an [Alchemy profile](/environments/profiles) before deployment. + +## Define the Worker + +```typescript title="src/worker.ts" +import { BetterAuth } from "@alchemy.run/better-auth"; +import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; + +const AuthDb = Cloudflare.D1.Database("AuthDb"); + +export default class AuthApi extends Cloudflare.Worker()( + "AuthApi", + { + main: import.meta.url, + compatibility: { flags: ["nodejs_compat"] }, + }, + Effect.gen(function* () { + const auth = yield* BetterAuth({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, + }); + return { fetch: auth.fetch }; + }).pipe(Effect.provide(CloudflareD1(AuthDb))), +) {} +``` + +`CloudflareD1` registers the native Worker binding and supplies Better Auth's database service. This auth-only Worker forwards every request to Better Auth; endpoints live under `/api/auth`. + +## Define the stack + +```typescript title="alchemy.run.ts" +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import AuthApi from "./src/worker.ts"; + +export default Alchemy.Stack( + "BetterAuthD1", + { + providers: Cloudflare.providers(), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const api = yield* AuthApi; + return { url: api.url.as() }; + }), +); +``` + +Omitting `secret` provisions and binds a stable signing secret automatically. Preserve and protect the local `.alchemy` state directory across deployments. + +## Deploy or develop locally + +```sh +bunx alchemy deploy +``` + +Deployment creates D1 and applies the auth schema through its HTTP API before updating the Worker. Requests use the native binding and never run migrations. + +```sh +bunx alchemy dev +``` + +Development uses the local D1 simulator, including migrations; use the URL printed by Alchemy. The Worker still runs in workerd, so replacing this layer with Bun's `SQLite` is not supported. + +## Verify the endpoint + +Set `AUTH_ORIGIN` to the URL printed by Alchemy, then request the session endpoint: + +```sh +curl -i "$AUTH_ORIGIN/api/auth/get-session" +``` + +## Transaction and migration limits + +D1 has no interactive transactions; plugins requiring them, including SCIM in Better Auth 1.7, need a different database. Automatic migrations are additive, not a data-transfer or destructive schema-migration tool. + +For existing applications, preserve the signing secret and review the [1.6 to 1.7 upgrade guide](/better-auth/upgrades/from-1-6-to-1-7). See [configuration](/better-auth/guides/configuration) for production origins, [migrations](/better-auth/guides/migrations) for schema ownership, and the [`CloudflareD1` reference](/providers/betterauth/cloudflared1) for layer options. diff --git a/.agents/skills/alchemy/references/better-auth/databases/cloudflare-hyperdrive.md b/.agents/skills/alchemy/references/better-auth/databases/cloudflare-hyperdrive.md new file mode 100644 index 00000000..59ae102a --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/databases/cloudflare-hyperdrive.md @@ -0,0 +1,121 @@ + + +# Cloudflare Hyperdrive + +> Deploy a Better Auth Worker with an uncached Hyperdrive connection to a Neon Postgres origin. + +This example provisions a Neon Postgres database, a Hyperdrive connection, and a Better Auth Worker. Hyperdrive provides pooled access to the origin; it does not create the origin database itself. + +## Install + +```sh +bun add @alchemy.run/better-auth better-auth@^1.7.5 kysely pg +``` + +Configure Cloudflare and Neon through [Alchemy profiles](/environments/profiles), or supply `NEON_API_KEY` for Neon. If you only need direct Neon access, the [Neon serverless layer](/better-auth/databases/neon) avoids Hyperdrive and `pg`. + +## Define the database layer + +```typescript title="src/auth-database.ts" +import { CloudflareHyperdrive } from "@alchemy.run/better-auth/CloudflareHyperdrive"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Neon from "alchemy/Neon"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +const AuthDb = Neon.Project("AuthDb"); + +export const AuthDatabase = Layer.unwrap( + Effect.gen(function* () { + const db = yield* AuthDb; + const connection = yield* Cloudflare.Hyperdrive.Connection("AuthConnection", { + origin: db.origin, + caching: { disabled: true }, + }); + return CloudflareHyperdrive(connection, { + migrate: db.connectionUri, + }); + }), +); +``` + +Keep Hyperdrive caching disabled so session and verification reads cannot return stale data. Migrations connect directly to the origin URL because Hyperdrive's runtime connection string exists only inside the Worker. + +## Define the auth Worker + +```typescript title="src/worker.ts" +import { BetterAuth } from "@alchemy.run/better-auth"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import { AuthDatabase } from "./auth-database.ts"; + +export default class AuthApi extends Cloudflare.Worker()( + "AuthApi", + { + main: import.meta.url, + compatibility: { flags: ["nodejs_compat"] }, + }, + Effect.gen(function* () { + const auth = yield* BetterAuth({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, + }); + return { fetch: auth.fetch }; + }).pipe(Effect.provide(AuthDatabase)), +) {} +``` + +The layer registers Hyperdrive's native binding and opens a driver pool inside each request, never during Worker construction. Better Auth's signing secret is generated and bound automatically. + +## Define the stack + +```typescript title="alchemy.run.ts" +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Neon from "alchemy/Neon"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import AuthApi from "./src/worker.ts"; + +export default Alchemy.Stack( + "BetterAuthHyperdrive", + { + providers: Layer.mergeAll(Cloudflare.providers(), Neon.providers()), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const api = yield* AuthApi; + return { url: api.url.as() }; + }), +); +``` + +Preserve and protect the local `.alchemy` state directory so later deployments retain resource identities and the signing secret. + +## Deploy and migrate through the origin + +```sh +bunx alchemy deploy +``` + +The deployer must reach the origin with schema-changing permissions; the migration completes before the Worker is updated. Set `AUTH_ORIGIN` to the printed URL and verify the endpoint: + +```sh +curl -i "$AUTH_ORIGIN/api/auth/get-session" +``` + +Omitting the database layer's `migrate` source disables automatic migration support. In that case, leave Better Auth's `migrate` unset or false and manage the schema yourself; explicitly requesting `migrate: true` fails. + +## MySQL origins + +For an existing MySQL origin, install its driver instead of `pg`: + +```sh +bun add mysql2 +``` + +Configure the Hyperdrive resource with that MySQL origin, select `dialect: "mysql"` in `CloudflareHyperdrive`, and supply the origin's deploy-resolvable URL as `migrate`. Keep caching disabled; changing the dialect alone neither converts the Neon origin above nor moves existing auth records. + +The `pool` option passes through to the selected driver, and migration credentials may come from a resource Output without binding them to the Worker. See [configuration](/better-auth/guides/configuration), [migrations](/better-auth/guides/migrations), the [upgrade guide](/better-auth/upgrades/from-1-6-to-1-7), and the [`CloudflareHyperdrive` reference](/providers/betterauth/cloudflarehyperdrive). diff --git a/.agents/skills/alchemy/references/better-auth/databases/drizzle.md b/.agents/skills/alchemy/references/better-auth/databases/drizzle.md new file mode 100644 index 00000000..db6f6537 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/databases/drizzle.md @@ -0,0 +1,193 @@ + + +# Drizzle + +> Generate a Relations v2 auth schema and deploy a Better Auth Lambda with a request-scoped raw Drizzle database. + +This example uses an existing Postgres database and serves Better Auth through a Lambda Function URL. Drizzle owns the schema and migration history; the Better Auth layer takes a raw `drizzle(...)` database, not an Alchemy Drizzle proxy that returns Effects. + +## Install compatible packages + +```sh +bun add @alchemy.run/better-auth better-auth@^1.7.5 @better-auth/drizzle-adapter@^1.7.5 pg drizzle-orm@1.0.0-rc.5-ab785fc +bun add -d drizzle-kit@1.0.0-rc.5-ab785fc @types/pg +``` + +Keep ORM and Kit versions compatible when upgrading. Configure an [AWS profile](/environments/profiles) and set `DATABASE_URL` to a Postgres database reachable by both your deployer and Lambda. + +## Export an upstream instance for the CLI + +```typescript title="auth.cli.ts" +import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2"; +import { betterAuth } from "better-auth"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; + +const url = process.env.DATABASE_URL; +if (!url) throw new Error("DATABASE_URL is required"); + +const database = drizzle({ + client: new Pool({ connectionString: url }), +}); + +export const auth = betterAuth({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, + database: drizzleAdapter(database, { provider: "pg" }), +}); +``` + +The CLI needs upstream `betterAuth`, not an Effect or a Context service. This CLI-only pool is never imported by the Lambda; the CLI instance is for schema generation, not serving requests. + +Keep plugins, additional fields, and model names aligned between this call and the Lambda's `BetterAuth` call below. Alchemy-only `id` and `migrate` options belong only in the runtime call. + +## Generate the auth schema + +Run the CLI with Node.js 22.12 or newer: + +```sh +npx auth@1.7.5 generate --config ./auth.cli.ts --output ./src/auth-schema.ts +``` + +This command creates the `src/auth-schema.ts` imported below, including auth tables and `authRelations`. Do not pass `--adapter drizzle`: it selects the legacy Relations v1 generator rather than the configured Relations v2 adapter. + +## Configure and apply migrations + +```typescript title="drizzle.config.ts" +import { defineConfig } from "drizzle-kit"; + +const url = process.env.DATABASE_URL; +if (!url) throw new Error("DATABASE_URL is required"); + +export default defineConfig({ + dialect: "postgresql", + schema: "./src/auth-schema.ts", + out: "./drizzle", + dbCredentials: { url }, +}); +``` + +If your application already owns a Drizzle migration history, include the generated auth schema there rather than starting a second history. + +```sh +bunx drizzle-kit generate +``` + +Review the generated SQL, especially for an existing database, before applying it: + +```sh +bunx drizzle-kit migrate +``` + +Apply migrations before deploying new auth code. This layer never runs Drizzle migrations for you. + +## Acquire the database per invocation + +```typescript title="src/auth-database.ts" +import { Drizzle } from "@alchemy.run/better-auth/Drizzle"; +import { drizzle } from "drizzle-orm/node-postgres"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import { Pool } from "pg"; +import * as schema from "./auth-schema.ts"; + +export const AuthDatabase = Layer.unwrap( + Effect.gen(function* () { + const url = yield* Config.Redacted("DATABASE_URL"); + const database = Effect.gen(function* () { + const client = yield* Effect.acquireRelease( + Effect.sync(() => new Pool({ + connectionString: Redacted.value(url), + max: 1, + })), + (pool) => Effect.promise(() => pool.end()), + ); + return yield* Effect.sync(() => drizzle({ + client, + relations: schema.authRelations, + })); + }); + return Drizzle(database, { provider: "pg", schema }); + }), +); +``` + +The URL resolves during host construction, while the inner Effect opens and releases the pool per invocation. Do not move `new Pool` to runtime module scope or layer construction. + +## Define the auth Lambda + +```typescript title="src/function.ts" +import { BetterAuth } from "@alchemy.run/better-auth"; +import * as AWS from "alchemy/AWS"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import { AuthDatabase } from "./auth-database.ts"; + +export default class AuthApi extends AWS.Lambda.Function()( + "AuthApi", + { + main: import.meta.url, + functionUrl: true, + memorySize: 512, + timeout: Duration.seconds(30), + build: { install: ["pg"] }, + }, + Effect.gen(function* () { + const auth = yield* BetterAuth({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, + migrate: false, + }); + return { fetch: auth.fetch }; + }).pipe(Effect.provide(AuthDatabase)), +) {} +``` + +The function exposes `/api/auth` through a public Function URL and packages `pg` in its deployment artifact. Alchemy generates and binds the signing secret automatically. + +## Define the stack + +```typescript title="alchemy.run.ts" +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; +import AuthApi from "./src/function.ts"; + +export default Alchemy.Stack( + "BetterAuthDrizzle", + { + providers: AWS.providers(), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const api = yield* AuthApi; + return { url: api.functionUrl }; + }), +); +``` + +Preserve and protect local `.alchemy` state across deployments. This example assumes a reachable Postgres origin; private databases also require Lambda VPC connectivity and security-group access. + +## Deploy and verify + +```sh +bunx alchemy deploy +``` + +Set `AUTH_ORIGIN` to the printed Function URL and check the endpoint: + +```sh +curl -i "$AUTH_ORIGIN/api/auth/get-session" +``` + +## Existing application relations + +When combining schemas, spread your application's Relations v2 `defineRelations` result before the generated `authRelations` in the raw database's `relations` option. Keep the generated auth tables in the adapter's `schema` option too; legacy `relations()` objects are not interchangeable. + +Drizzle has no wrapper-managed migration support, so leave Better Auth's `migrate` unset or false; setting it to true fails. On Workers, keep the same per-request acquisition pattern, enable `nodejs_compat`, and choose a driver and origin reachable from workerd. + +See [configuration](/better-auth/guides/configuration), [migrations](/better-auth/guides/migrations), the [upgrade guide](/better-auth/upgrades/from-1-6-to-1-7), and the [`Drizzle` reference](/providers/betterauth/drizzle). diff --git a/.agents/skills/alchemy/references/better-auth/databases/memory.md b/.agents/skills/alchemy/references/better-auth/databases/memory.md new file mode 100644 index 00000000..012af385 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/databases/memory.md @@ -0,0 +1,111 @@ + + +# Memory + +> Run a disposable Better Auth Worker with an isolate-local memory store. + +Use `Memory` for throwaway development and tests, not production authentication. This complete Worker example keeps records only for the lifetime of each isolate. + +## Install + +```sh +bun add @alchemy.run/better-auth better-auth@^1.7.5 +``` + +No optional database driver or `kysely` peer is required by the memory adapter. + +## Define the database layer + +```typescript title="src/auth-database.ts" +import { Memory } from "@alchemy.run/better-auth/Memory"; + +export const AuthDatabase = Memory(); +``` + +Construct the layer once for the host, not inside `fetch`, so requests in the same isolate share its store. + +## Define the auth Worker + +```typescript title="src/worker.ts" +import { BetterAuth } from "@alchemy.run/better-auth"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import { AuthDatabase } from "./auth-database.ts"; + +export default class AuthApi extends Cloudflare.Worker()( + "AuthApi", + { + main: import.meta.url, + compatibility: { flags: ["nodejs_compat"] }, + }, + Effect.gen(function* () { + const auth = yield* BetterAuth({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, + migrate: false, + }); + return { fetch: auth.fetch }; + }).pipe(Effect.provide(AuthDatabase)), +) {} +``` + +Better Auth instances remain request-scoped, while the layer retains the backing records across requests. Separate Worker isolates or Lambda instances never share these records. + +## Define the stack + +```typescript title="alchemy.run.ts" +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import AuthApi from "./src/worker.ts"; + +export default Alchemy.Stack( + "BetterAuthMemory", + { + providers: Cloudflare.providers(), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const api = yield* AuthApi; + return { url: api.url.as() }; + }), +); +``` + +Alchemy generates and binds a stable signing secret, persisted in local stack state. That secret does not make the memory store persistent. + +## Run locally + +```sh +bunx alchemy dev +``` + +Use the printed URL rather than assuming a local port. Set `AUTH_ORIGIN` to that URL to check the auth endpoint: + +```sh +curl -i "$AUTH_ORIGIN/api/auth/get-session" +``` + +A restart or isolate replacement loses users and sessions. Even without a restart, a deployed request routed to a different isolate may not find a previously created user. + +## Inspect records in tests + +When an in-process test needs access to the backing store, replace `src/auth-database.ts` with this file: + +```typescript title="src/auth-database.ts" +import { Memory } from "@alchemy.run/better-auth/Memory"; +import type { MemoryDB } from "better-auth/adapters/memory"; + +export const records: MemoryDB = {}; +export const AuthDatabase = Memory(records); +``` + +The adapter initializes missing model arrays from the auth schema, including plugins. Give independent tests independent stores; importing this module in the test process does not expose a separate Worker's in-memory records. + +## Migration behavior + +Memory has no migration support and needs no schema command. Leave `migrate` unset or false; explicitly setting it to true fails rather than pretending to migrate. + +Choose [D1](/better-auth/databases/cloudflare-d1) or another durable database before serving real users. See [configuration](/better-auth/guides/configuration), [migrations](/better-auth/guides/migrations), and the [`Memory` reference](/providers/betterauth/memory). diff --git a/.agents/skills/alchemy/references/better-auth/databases/mysql.md b/.agents/skills/alchemy/references/better-auth/databases/mysql.md new file mode 100644 index 00000000..bcb21ca6 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/databases/mysql.md @@ -0,0 +1,134 @@ + + +# MySQL + +> Deploy Better Auth on Lambda with an existing MySQL database and a request-scoped mysql2 pool. + +This example connects a Better Auth Lambda to an existing MySQL database over TCP with TLS. The database, credentials, and network access must already exist. + +## Install + +```sh +bun add @alchemy.run/better-auth better-auth@^1.7.5 kysely mysql2 +``` + +Configure AWS through an [Alchemy profile](/environments/profiles) and set `DATABASE_URL` in the deploying environment. Both the deployer and Lambda must be able to reach that URL. + +## Define the database layer + +```typescript title="src/auth-database.ts" +import { MySQL } from "@alchemy.run/better-auth/MySQL"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export const AuthDatabase = Layer.unwrap( + Effect.gen(function* () { + const url = yield* Config.Redacted("DATABASE_URL"); + return MySQL(url, { + pool: { ssl: { rejectUnauthorized: true } }, + }); + }), +); +``` + +The URL is bound during host construction; the `mysql2` pool opens per invocation and closes when it settles. The TLS configuration suits hosted services such as PlanetScale; provide your database's CA when it is not covered by the system trust store. + +## Define the auth Lambda + +```typescript title="src/function.ts" +import { BetterAuth } from "@alchemy.run/better-auth"; +import * as AWS from "alchemy/AWS"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import { AuthDatabase } from "./auth-database.ts"; + +export default class AuthApi extends AWS.Lambda.Function()( + "AuthApi", + { + main: import.meta.url, + functionUrl: true, + memorySize: 512, + timeout: Duration.seconds(30), + build: { install: ["mysql2"] }, + }, + Effect.gen(function* () { + const auth = yield* BetterAuth({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, + }); + return { fetch: auth.fetch }; + }).pipe(Effect.provide(AuthDatabase)), +) {} +``` + +The function exposes Better Auth at `/api/auth` through a public Function URL. The driver is installed in the deployment artifact, and the signing secret is generated and bound automatically. + +## Define the stack + +```typescript title="alchemy.run.ts" +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; +import AuthApi from "./src/function.ts"; + +export default Alchemy.Stack( + "BetterAuthMySQL", + { + providers: AWS.providers(), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const api = yield* AuthApi; + return { url: api.functionUrl }; + }), +); +``` + +Preserve and protect the local `.alchemy` state directory across deployments. The example assumes network access without VPC attachment; a private database also needs Lambda VPC and security-group configuration. + +## Deploy and migrate + +```sh +bunx alchemy deploy +``` + +Automatic migrations use the resolved URL and the same pool options before updating the function. The deployer needs schema-changing permissions; migrations create auth tables inside the existing database. + +Set `AUTH_ORIGIN` to the printed Function URL and check the endpoint: + +```sh +curl -i "$AUTH_ORIGIN/api/auth/get-session" +``` + +## Separate migration permissions + +Set `DATABASE_MIGRATION_URL` alongside the runtime URL and replace `src/auth-database.ts` with this file: + +```typescript title="src/auth-database.ts" +import { MySQL } from "@alchemy.run/better-auth/MySQL"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export const AuthDatabase = Layer.unwrap( + Effect.gen(function* () { + const url = yield* Config.Redacted("DATABASE_URL"); + const migrationUrl = yield* Config.Redacted("DATABASE_MIGRATION_URL"); + return MySQL(url, { + migrate: migrationUrl, + pool: { ssl: { rejectUnauthorized: true } }, + }); + }), +); +``` + +Configuration read during host construction is bound into its environment, including this migration URL. Use a deploy-only resource Output instead when the runtime must not receive privileged migration credentials. + +## Other hosts and existing schemas + +For Workers, prefer [Hyperdrive](/better-auth/databases/cloudflare-hyperdrive) with a MySQL origin and `nodejs_compat`. Changing adapters does not transfer users or sessions between databases. + +Review MySQL index and string-length constraints in the [1.6 to 1.7 upgrade guide](/better-auth/upgrades/from-1-6-to-1-7) before upgrading a populated database. See [configuration](/better-auth/guides/configuration), [migrations](/better-auth/guides/migrations), and the [`MySQL` reference](/providers/betterauth/mysql). diff --git a/.agents/skills/alchemy/references/better-auth/databases/neon.md b/.agents/skills/alchemy/references/better-auth/databases/neon.md new file mode 100644 index 00000000..8cd6d3f3 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/databases/neon.md @@ -0,0 +1,125 @@ + + +# Neon + +> Deploy a Better Auth Worker with a Neon project and the serverless database driver. + +This example provisions Neon and serves Better Auth from a Cloudflare Worker. The serverless driver uses WebSockets, so it needs neither Hyperdrive nor `pg`. + +## Install + +```sh +bun add @alchemy.run/better-auth better-auth@^1.7.5 kysely @neondatabase/serverless +``` + +Configure Cloudflare and Neon through [Alchemy profiles](/environments/profiles), or supply `NEON_API_KEY` for Neon. You provide account credentials; the project resource creates the database, not the API key. + +## Define the database layer + +```typescript title="src/auth-database.ts" +import { Neon as NeonDatabase } from "@alchemy.run/better-auth/Neon"; +import * as Neon from "alchemy/Neon"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +const AuthDb = Neon.Project("AuthDb"); + +export const AuthDatabase = Layer.unwrap( + Effect.map(AuthDb, (db) => NeonDatabase(db.connectionUri)), +); +``` + +The project's `connectionUri` Output is bound into the host environment and also supplies deploy-time migrations. The driver acquires one pool per request and closes it when that request settles. + +## Define the auth Worker + +```typescript title="src/worker.ts" +import { BetterAuth } from "@alchemy.run/better-auth"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import { AuthDatabase } from "./auth-database.ts"; + +export default class AuthApi extends Cloudflare.Worker()( + "AuthApi", + { + main: import.meta.url, + compatibility: { flags: ["nodejs_compat"] }, + }, + Effect.gen(function* () { + const auth = yield* BetterAuth({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, + }); + return { fetch: auth.fetch }; + }).pipe(Effect.provide(AuthDatabase)), +) {} +``` + +This is an auth-only endpoint, with no frontend or application routes. Alchemy generates and binds the signing secret automatically. + +## Define the stack + +```typescript title="alchemy.run.ts" +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Neon from "alchemy/Neon"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import AuthApi from "./src/worker.ts"; + +export default Alchemy.Stack( + "BetterAuthNeon", + { + providers: Layer.mergeAll(Cloudflare.providers(), Neon.providers()), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const api = yield* AuthApi; + return { url: api.url.as() }; + }), +); +``` + +Keep the local `.alchemy` state directory protected and persistent so subsequent deployments retain resource identities and the signing secret. + +## Deploy and verify + +```sh +bunx alchemy deploy +``` + +The deployer must reach Neon with schema-changing permissions; migrations complete before the Worker is updated. Set `AUTH_ORIGIN` to the printed URL to check the endpoint: + +```sh +curl -i "$AUTH_ORIGIN/api/auth/get-session" +``` + +## Connect an existing project + +To use an existing Neon URL, replace only `src/auth-database.ts` with this complete file: + +```typescript title="src/auth-database.ts" +import { Neon } from "@alchemy.run/better-auth/Neon"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export const AuthDatabase = Layer.unwrap( + Effect.gen(function* () { + const url = yield* Config.Redacted("DATABASE_URL"); + return Neon(url); + }), +); +``` + +Set `DATABASE_URL` in the deploying environment; it is read and bound during host construction, not first discovered inside a request. The stack above still works, but this variant creates no Neon resource and does not need Neon API credentials. + +## Other connection layouts + +The same database layer supports Lambda, where the pool is scoped to each invocation. For stage isolation, a `Neon.Branch` connection URI can replace the project URI; register `Neon.providers()` whenever the stack provisions Neon resources. + +The layer's `migrate` option accepts a separate deploy-resolvable connection source, or `false` to disable its migration support. Configuration read during host construction is bound to the host, so use a resource Output for migration credentials that must remain deploy-only. + +Automatic migrations do not move existing auth records between databases. See [configuration](/better-auth/guides/configuration), [migrations](/better-auth/guides/migrations), the [upgrade guide](/better-auth/upgrades/from-1-6-to-1-7), and the [`Neon` reference](/providers/betterauth/neon). diff --git a/.agents/skills/alchemy/references/better-auth/databases/postgres.md b/.agents/skills/alchemy/references/better-auth/databases/postgres.md new file mode 100644 index 00000000..9894b843 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/databases/postgres.md @@ -0,0 +1,131 @@ + + +# Postgres + +> Deploy Better Auth on Lambda with an existing PostgreSQL database and a request-scoped pg pool. + +This example serves Better Auth through a Lambda Function URL using an existing PostgreSQL database. Both the Lambda and the deploying machine must be able to reach the server over TCP. + +## Install + +```sh +bun add @alchemy.run/better-auth better-auth@^1.7.5 kysely pg +``` + +Configure AWS through an [Alchemy profile](/environments/profiles) and set `DATABASE_URL` in the deploying environment. The connection layer does not create the database, credentials, or network access. + +## Define the database layer + +```typescript title="src/auth-database.ts" +import { Postgres } from "@alchemy.run/better-auth/Postgres"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export const AuthDatabase = Layer.unwrap( + Effect.gen(function* () { + const url = yield* Config.Redacted("DATABASE_URL"); + return Postgres(url); + }), +); +``` + +Configuration resolves during host construction and is bound into the Lambda environment. The pool is not opened until an auth operation runs, and closes at the end of the invocation. + +## Define the auth Lambda + +```typescript title="src/function.ts" +import { BetterAuth } from "@alchemy.run/better-auth"; +import * as AWS from "alchemy/AWS"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import { AuthDatabase } from "./auth-database.ts"; + +export default class AuthApi extends AWS.Lambda.Function()( + "AuthApi", + { + main: import.meta.url, + functionUrl: true, + memorySize: 512, + timeout: Duration.seconds(30), + build: { install: ["pg"] }, + }, + Effect.gen(function* () { + const auth = yield* BetterAuth({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, + }); + return { fetch: auth.fetch }; + }).pipe(Effect.provide(AuthDatabase)), +) {} +``` + +`build.install` packages the dynamically imported `pg` driver with a conventional npm layout. `functionUrl: true` creates a public endpoint; Better Auth handles authentication under `/api/auth`. + +## Define the stack + +```typescript title="alchemy.run.ts" +import * as Alchemy from "alchemy"; +import * as AWS from "alchemy/AWS"; +import * as Effect from "effect/Effect"; +import AuthApi from "./src/function.ts"; + +export default Alchemy.Stack( + "BetterAuthPostgres", + { + providers: AWS.providers(), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const api = yield* AuthApi; + return { url: api.functionUrl }; + }), +); +``` + +Alchemy generates and binds the signing secret automatically. Preserve and protect `.alchemy` state to retain it across deployments. + +## Deploy and migrate + +```sh +bunx alchemy deploy +``` + +Automatic migrations use the resolved URL before updating the function, so the deployer needs schema-changing permissions. The runtime pool defaults to one connection per invocation; migrations create auth tables, not the database itself. + +Set `AUTH_ORIGIN` to the printed Function URL to verify the endpoint: + +```sh +curl -i "$AUTH_ORIGIN/api/auth/get-session" +``` + +## Use separate migration credentials + +For a restricted runtime role or a pooled runtime URL, set `DATABASE_MIGRATION_URL` as well and replace `src/auth-database.ts`: + +```typescript title="src/auth-database.ts" +import { Postgres } from "@alchemy.run/better-auth/Postgres"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export const AuthDatabase = Layer.unwrap( + Effect.gen(function* () { + const url = yield* Config.Redacted("DATABASE_URL"); + const migrationUrl = yield* Config.Redacted("DATABASE_MIGRATION_URL"); + return Postgres(url, { migrate: migrationUrl }); + }), +); +``` + +Both configuration values are bound to the host because they are read during construction. For migration credentials that must remain deploy-only, supply a resource Output as `migrate` rather than reading privileged credentials through host configuration. + +## Network and host requirements + +The Lambda above assumes a server reachable without VPC attachment; private RDS requires Lambda VPC connectivity and security-group access. Configure TLS according to your database provider, without disabling certificate verification. + +`Postgres` also accepts connection-string resource Outputs, such as PlanetScale's `role.connectionUrl`. For Workers, prefer [Hyperdrive](/better-auth/databases/cloudflare-hyperdrive) or [Neon](/better-auth/databases/neon); direct TCP requires `nodejs_compat` and a reachable origin. + +Changing the connection does not transfer auth records, and automatic migrations are additive. See [configuration](/better-auth/guides/configuration), [migrations](/better-auth/guides/migrations), the [upgrade guide](/better-auth/upgrades/from-1-6-to-1-7), and the [`Postgres` reference](/providers/betterauth/postgres). diff --git a/.agents/skills/alchemy/references/better-auth/databases/sqlite.md b/.agents/skills/alchemy/references/better-auth/databases/sqlite.md new file mode 100644 index 00000000..796eda1a --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/databases/sqlite.md @@ -0,0 +1,129 @@ + + +# SQLite + +> Run Better Auth in a local Bun server with a persistent SQLite file. + +`SQLite` uses Bun's built-in `bun:sqlite` driver, not a cloud database. This example runs a local Bun server through `Command.Dev`; a Worker, including one started by `alchemy dev`, must use [D1](/better-auth/databases/cloudflare-d1) instead. + +## Install + +```sh +bun add "@effect/platform-bun@4.0.0-rc.115" @alchemy.run/better-auth better-auth@^1.7.5 kysely +``` + +Bun supplies the driver, while `kysely` supports the SQL adapter and schema migration. A standard Node.js Lambda cannot load `bun:sqlite`. + +## Define the Bun server + +```typescript title="src/server.ts" +import { BetterAuth, Database } from "@alchemy.run/better-auth"; +import { SQLite } from "@alchemy.run/better-auth/SQLite"; +import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import { RuntimeContext } from "alchemy"; +import { getMigrations } from "better-auth/db/migration"; +import * as Config from "effect/Config"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; +import * as HttpServer from "effect/http/HttpServer"; + +const program = Effect.gen(function* () { + const secret = yield* Config.Redacted("AUTH_SECRET"); + const database = yield* Database; + + yield* Effect.gen(function* () { + const connect = yield* database.migrate!.connect; + const raw = yield* connect; + const migrations = yield* Effect.tryPromise(() => getMigrations({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, + database: raw, + secret: Redacted.value(secret), + })); + yield* Effect.tryPromise(() => migrations.runMigrations()); + }).pipe(Effect.scoped); + + const auth = yield* BetterAuth({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, + secret, + migrate: false, + }); + const server = yield* BunHttpServer.make({ + hostname: "127.0.0.1", + port: 0, + }); + yield* server.serve(auth.fetch); + yield* Console.log(HttpServer.formatAddress(server.address)); + yield* Effect.never; +}).pipe( + Effect.provide(SQLite("./auth.sqlite")), + Effect.provide(RuntimeContext.phantom), + Effect.scoped, + Effect.provide(BunServices.layer), +); + +BunRuntime.runMain(program); +``` + +The standalone Bun process has no Alchemy migration Action, so it applies the schema once at startup before listening. Keep schema-related options aligned between `getMigrations` and `BetterAuth` when adding plugins or fields. + +`SQLite` supplies migration support, and the scoped migration connection closes before requests begin. + +The Effect HTTP server provides a fresh scope per request; Better Auth opens and closes its SQLite connection within that scope. `RuntimeContext.phantom` satisfies the runtime-only methods here because this process has no resource Output accessors to resolve. + +## Define the local stack + +```typescript title="alchemy.run.ts" +import * as Alchemy from "alchemy"; +import * as Command from "alchemy/Command"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export default Alchemy.Stack( + "BetterAuthSQLite", + { + providers: Layer.mergeAll(Command.providers(), Alchemy.RandomProvider()), + state: Alchemy.localState(), + }, + Effect.gen(function* () { + const secret = yield* Alchemy.Random("BetterAuthSecret"); + const server = yield* Command.Dev("AuthApi", { + command: "bun run src/server.ts", + env: { AUTH_SECRET: secret.text }, + }); + return { url: server.url }; + }), +); +``` + +The stack generates a stable signing secret and passes it to the child process automatically; do not create or export `AUTH_SECRET` yourself. This explicit command binding replaces the automatic binding provided by built-in Worker and Lambda hosts. + +## Start locally + +```sh +bunx alchemy dev +``` + +The server asks the OS for an available port and prints its URL, which Alchemy exposes as the stack output. `Command.Dev` runs only during development; `alchemy deploy` does not publish this local server. + +Set `AUTH_ORIGIN` to the printed URL and check the endpoint: + +```sh +curl -i "$AUTH_ORIGIN/api/auth/get-session" +``` + +## File and migration lifetime + +`./auth.sqlite` is relative to the command's working directory, and its parent directory must exist. Protect and back up both the database and `.alchemy` state; deleting the latter loses the generated signing secret. + +For an Alchemy host that supplies `AlchemyContext`, `SQLite()` defaults to `better-auth.sqlite` in its resolved runtime directory, normally `.alchemy`. The standalone server above uses an explicit filename so it does not require that context. + +Use a persistent filename rather than `:memory:` because migration and request connections are separate. Deploy-time migrations in a built-in Alchemy host affect the deployer's file; they do not copy that file to another machine. + +The startup migration pattern here is for local development; review schema changes before pointing it at valuable data. See [migrations](/better-auth/guides/migrations), the [upgrade guide](/better-auth/upgrades/from-1-6-to-1-7), and the [`SQLite` reference](/providers/betterauth/sqlite). diff --git a/.agents/skills/alchemy/references/better-auth/guides/configuration.md b/.agents/skills/alchemy/references/better-auth/guides/configuration.md new file mode 100644 index 00000000..92149e17 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/guides/configuration.md @@ -0,0 +1,123 @@ + + +# Config and secrets + +> Bind Effect Config into an Alchemy host, compose database layers, and preserve generated signing secrets. + +## Keep configuration in a typed service + +```typescript +// auth.ts +import { BetterAuth } from "@alchemy.run/better-auth"; +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +const makeAuth = Effect.gen(function* () { + const baseURL = yield* Config.String("AUTH_BASE_URL").pipe(Config.option); + return yield* BetterAuth({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, + baseURL: Option.getOrUndefined(baseURL), + }); +}); + +export class Auth extends Context.Service< + Auth, + Effect.Success +>()("app/Auth") { + static readonly layer = Layer.effect(Auth, makeAuth); +} +``` + +`BetterAuth` checks the inline options and infers plugin types. The wrapper owns `database` and `secondaryStorage`; supply those through layers rather than options. + +`AUTH_BASE_URL` is optional and contains an origin without a trailing slash when supplied. Resolving Config during service construction lets Alchemy discover and bind it before requests arrive. + +## Supply the database at the host + +```typescript +// worker.ts +import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpServerRequest } from "effect/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; +import { Auth } from "./auth.ts"; + +const AuthDb = Cloudflare.D1.Database("AuthDb"); + +export default class AuthApi extends Cloudflare.Worker()( + "AuthApi", + { main: import.meta.url, compatibility: { flags: ["nodejs_compat"] } }, + Effect.gen(function* () { + const auth = yield* Auth; + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const path = request.url.split("?")[0]; + if (path === "/api/auth" || path.startsWith("/api/auth/")) { + return yield* auth.fetch; + } + return HttpServerResponse.text("Not found", { status: 404 }); + }), + }; + }).pipe( + Effect.provide(Auth.layer.pipe(Layer.provide(CloudflareD1(AuthDb)))), + ), +) {} +``` + +This host creates its own D1 database, registers migrations, and forwards Better Auth's complete HTTP responses. The Auth service itself has no Cloudflare dependency. + +## Run the standalone stack + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import AuthApi from "./worker.ts"; + +export default Alchemy.Stack( + "AuthConfiguration", + { providers: Cloudflare.providers(), state: Alchemy.localState() }, + Effect.gen(function* () { + const api = yield* AuthApi; + return { url: api.url }; + }), +); +``` + +These three files use Bun, matching releases of `alchemy` and `@alchemy.run/better-auth`, Better Auth 1.7.5, Effect 4, and `kysely`. Configure Cloudflare credentials for deployment; preserve the local Alchemy state directory across runs. + +```sh +bunx alchemy dev +``` + +Alchemy selects an available port and prints the origin. With `AUTH_BASE_URL` unset, Better Auth derives its origin from the incoming request, so no fixed port is needed. + +## Override the origin deliberately + +```sh +read -r AUTH_BASE_URL +export AUTH_BASE_URL +bunx alchemy deploy --stage prod +``` + +Enter the actual origin serving this Worker, without a path or trailing slash. `baseURL` does not provision DNS or attach a custom domain; configure that routing separately before sending traffic there. + +A gitignored env file can supply the same value through `--env-file .env.production`. Pass the file explicitly on subsequent deployments, and update any local override if Alchemy's printed origin changes. + +`--stage prod` selects the stack instance; `--profile prod` selects cloud credentials. Neither option implicitly loads an env file or selects an application URL; keep local development on a separate stage. + +## Preserve the signing identity + +Omit `secret` for a new application: Alchemy provisions a random signing secret once and preserves it across deployments. Keep the auth instance's `id`, resource namespace, stage, and state stable. + +When importing an existing installation, preserve its existing signing identity through protected configuration rather than generating a replacement. Preserving a secret alone does not migrate users, sessions, or cookies; follow the [upgrade guidance](/better-auth/upgrades/from-1-6-to-1-7) before moving an existing database. diff --git a/.agents/skills/alchemy/references/better-auth/guides/http-api-middleware.md b/.agents/skills/alchemy/references/better-auth/guides/http-api-middleware.md new file mode 100644 index 00000000..70eb8834 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/guides/http-api-middleware.md @@ -0,0 +1,186 @@ + + +# HTTP API middleware + +> Authenticate each request and provide a typed current user to Effect HTTP API handlers. + +## Define the service, middleware, and protected API + +```typescript +// worker.ts +import { BetterAuth } from "@alchemy.run/better-auth"; +import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; +import { RuntimeContext } from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Http from "alchemy/Http"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as HttpRouter from "effect/http/HttpRouter"; +import { HttpServerRequest } from "effect/http/HttpServerRequest"; +import * as HttpApi from "effect/http-api/HttpApi"; +import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; +import * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint"; +import * as HttpApiGroup from "effect/http-api/HttpApiGroup"; +import * as HttpApiMiddleware from "effect/http-api/HttpApiMiddleware"; + +const makeAuth = BetterAuth({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, +}); + +class Auth extends Context.Service< + Auth, + Effect.Success +>()("app/Auth") { + static readonly layer = Layer.effect(Auth, makeAuth); +} + +const User = Schema.Struct({ + id: Schema.String, + name: Schema.String, + email: Schema.String, +}); + +class CurrentUser extends Context.Service< + CurrentUser, + typeof User.Type +>()("app/CurrentUser") {} + +class Unauthorized extends Schema.TaggedError()( + "Unauthorized", {}, { httpApiStatus: 401 }, +) {} + +class AuthenticationUnavailable extends Schema.TaggedError()( + "AuthenticationUnavailable", {}, { httpApiStatus: 503 }, +) {} + +class Authentication extends HttpApiMiddleware.Service< + Authentication, + { provides: CurrentUser } +>()("app/Authentication", { + error: [Unauthorized, AuthenticationUnavailable], +}) { + static readonly layer = Layer.effect( + Authentication, + Effect.gen(function* () { + const auth = yield* Auth; + return (httpEffect) => Effect.gen(function* () { + const session = yield* auth.getSession().pipe( + Effect.mapError(() => new AuthenticationUnavailable()), + Effect.catchDefect(() => Effect.fail(new AuthenticationUnavailable())), + ); + if (session === null) return yield* Effect.fail(new Unauthorized()); + return yield* Effect.provideService(httpEffect, CurrentUser, { + id: session.user.id, + name: session.user.name, + email: session.user.email, + }); + }).pipe(Effect.provide(RuntimeContext.phantom)); + }), + ); +} + +class PrivateApi extends HttpApiGroup.make("private") + .add(HttpApiEndpoint.get("me", "/api/me", { success: User })) + .middleware(Authentication) {} + +class PublicApi extends HttpApiGroup.make("public").add( + HttpApiEndpoint.get("health", "/api/health", { + success: Schema.Struct({ ok: Schema.Boolean }), + }), +) {} + +class AppApi extends HttpApi.make("app").add(PublicApi).add(PrivateApi) {} + +const PrivateLive = HttpApiBuilder.group(AppApi, "private", (handlers) => + handlers.handle("me", () => CurrentUser), +); +const PublicLive = HttpApiBuilder.group(AppApi, "public", (handlers) => + handlers.handle("health", () => Effect.succeed({ ok: true })), +); +const HttpLive = HttpApiBuilder.layer(AppApi).pipe( + Layer.provide(Layer.merge(PublicLive, PrivateLive)), + Layer.provide(Authentication.layer), + Layer.provide(Http.Platform), +); + +const AuthDb = Cloudflare.D1.Database("AuthDb"); + +export default class AuthApi extends Cloudflare.Worker()( + "AuthApi", + { main: import.meta.url, compatibility: { flags: ["nodejs_compat"] } }, + Effect.gen(function* () { + const auth = yield* Auth; + const fetch = yield* HttpRouter.toHttpEffect(HttpLive); + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const path = request.url.split("?")[0]; + if (path === "/api/auth" || path.startsWith("/api/auth/")) { + return yield* auth.fetch; + } + return yield* fetch; + }), + }; + }).pipe( + Effect.provide(Auth.layer.pipe(Layer.provide(CloudflareD1(AuthDb)))), + ), +) {} +``` + +The layer captures `Auth` once, but reads the session inside each returned request effect. It provides only that request's user to the handler; never construct a process-wide user layer or accept identity from a request body. + +`auth.getSession()` reads headers from the ambient Effect HTTP request. Outside that boundary, `auth.getSession(headers)` accepts a web `Headers` object. + +Only `null` becomes `Unauthorized` with HTTP `401`; typed lookup failures and unexpected lookup defects become `AuthenticationUnavailable` with HTTP `503`. The catch surrounds session resolution, not the downstream handler, so it cannot hide unrelated application failures. + +For diagnostics, `BetterAuthApiError.statusCode` is numeric and `body.code` identifies the upstream error. Log those fields rather than the complete error, whose headers and cause can contain credentials. + +`RuntimeContext.phantom` adapts Alchemy's runtime marker to the native HTTP middleware boundary. It does not authenticate a request or provide a session. + +The private group requires authentication, while the health group and Better Auth routes remain public. Dispatching to `auth.fetch` first preserves status, redirects, and `Set-Cookie` headers, including cookies written during session refresh. + +## Deploy the standalone example + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import AuthApi from "./worker.ts"; + +export default Alchemy.Stack( + "AuthMiddleware", + { providers: Cloudflare.providers(), state: Alchemy.localState() }, + Effect.gen(function* () { + const api = yield* AuthApi; + return { url: api.url }; + }), +); +``` + +Use Bun, matching `alchemy` and `@alchemy.run/better-auth` releases, Better Auth 1.7.5, Effect 4, and `kysely`, with Cloudflare credentials configured. This stack owns its D1 database, migrations, and stable signing secret; preserve its local state. + +```sh +bunx alchemy dev +``` + +Alchemy selects an available port and prints the origin. In another shell, enter that origin without a trailing slash after `read`: + +```sh +read -r BASE_URL +curl -i "$BASE_URL/api/me" +curl -i "$BASE_URL/api/health" +``` + +The first request returns `401` with `{"_tag":"Unauthorized"}`; the second returns `200` with `{"ok":true}`. An authenticated `/api/me` response contains only `id`, `name`, and `email`, not a session token. + +## Keep optional authentication explicit + +An intentionally public endpoint can call `auth.getSession()` within its request effect and treat only `null` as anonymous. Keep lookup failures in the error channel or map them to a declared server error; do not attach required-auth middleware merely to obtain optional identity. + +Authentication identifies the caller; authorization still needs server-side ownership or permission checks. For session lifetime, revocation, and cookie-cache behavior, see [Better Auth's session documentation](https://better-auth.com/docs/concepts/session-management). diff --git a/.agents/skills/alchemy/references/better-auth/guides/migrations.md b/.agents/skills/alchemy/references/better-auth/guides/migrations.md new file mode 100644 index 00000000..4c1759fd --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/guides/migrations.md @@ -0,0 +1,125 @@ + + +# Migrations + +> Apply Better Auth schema changes at deploy, or manage the schema yourself. + +## Deploy an additional field + +```typescript +// worker.ts +import { BetterAuth } from "@alchemy.run/better-auth"; +import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; + +const AuthDb = Cloudflare.D1.Database("AuthDb"); + +export default class AuthApi extends Cloudflare.Worker()( + "AuthApi", + { main: import.meta.url, compatibility: { flags: ["nodejs_compat"] } }, + Effect.gen(function* () { + const auth = yield* BetterAuth({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, + user: { + additionalFields: { + timezone: { type: "string", required: false }, + }, + }, + }); + return { fetch: auth.fetch }; + }).pipe(Effect.provide(CloudflareD1(AuthDb))), +) {} +``` + +This auth-only Worker declares an optional `timezone` column on the user model. The database layer supplies both the runtime binding and the deploy-time migration connection. + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import AuthApi from "./worker.ts"; + +export default Alchemy.Stack( + "AuthMigrations", + { providers: Cloudflare.providers(), state: Alchemy.localState() }, + Effect.gen(function* () { + const api = yield* AuthApi; + return { url: api.url }; + }), +); +``` + +Use Bun, matching `alchemy` and `@alchemy.run/better-auth` releases, Better Auth 1.7.5, Effect 4, and `kysely`, with Cloudflare credentials configured. This standalone stack creates a new database; applying a change to an existing installation requires retaining its stack, stage, resource identities, and state. + +```sh +bunx alchemy deploy +``` + +The migration action applies missing tables, columns, and indexes before the updated host deploys, while existing instances may still serve traffic. Migration code does not run on incoming requests or during planning. + +## Repeat or force a deployment + +```sh +bunx alchemy deploy +``` + +An unchanged schema, database identity, and dialect skip the migration action. Plugin fields, model names, and indexes participate in the schema fingerprint. + +```sh +bunx alchemy deploy --force +``` + +Force reapplies the deployment, including its migration action; an already-current schema remains a no-op. It does not override migration safety checks, merge users, or rewrite account identities. + +## Handle existing data safely + +Back up the database, rehearse on a copy, and resolve incompatible data before production deployment. Unsafe schema changes fail with `BetterAuthMigrationError`; repeatedly forcing deployment does not make them safe. + +Use a maintenance window for changes incompatible with running instances. Rolling back application code does not undo database changes, and automatic additive migrations are not a data-conversion or rename plan. + +## Choose the migration transport + +| Database layer | Migration connection | +| --- | --- | +| D1 | D1 HTTP API, or the local simulator | +| Neon | Serverless driver and supplied connection URL | +| Aurora Data API | RDS Data API with deployment credentials | +| Hyperdrive | Origin connection supplied to the layer | +| Postgres / MySQL | Connection URL, or a separate migration connection | +| SQLite | Same local database file | +| Drizzle | Application-owned migration tooling | +| Memory | No SQL migration | + +Runtime connectivity does not prove deploy-time connectivity. In particular, Hyperdrive migrations need access to the origin database rather than the Worker's runtime binding. + +## Opt out only when another process owns the schema + +In `worker.ts` above, replace the `BetterAuth` call with: + +```typescript +const auth = yield* BetterAuth({ + basePath: "/api/auth", + migrate: false, + emailAndPassword: { enabled: true }, + user: { + additionalFields: { + timezone: { type: "string", required: false }, + }, + }, +}); +``` + +With `migrate: false`, your schema pipeline must create the matching tables, columns, and indexes before deployment. Do not use it to hide a failed migration; requesting `migrate: true` with Memory or Drizzle fails because those layers do not support automatic migrations. + +Upstream schema generation needs a separate, CLI-loadable Better Auth configuration using the upstream database or adapter, not an Alchemy stack. The exact configuration depends on your externally managed database and migration tooling; no external CLI configuration is assumed by the automatic example above. + +## Separate multiple auth instances + +Distinct `id` values give multiple Better Auth instances separate migration actions and generated secrets. They do not isolate tables: use separate databases or explicit model names when isolation is required. + +Changing IDs or creating a new stage is not an upgrade of an existing installation. For an existing 1.6 database, follow the [1.6 to 1.7 upgrade guide](/better-auth/upgrades/from-1-6-to-1-7) before deployment. diff --git a/.agents/skills/alchemy/references/better-auth/guides/secondary-storage.md b/.agents/skills/alchemy/references/better-auth/guides/secondary-storage.md new file mode 100644 index 00000000..99a64a88 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/guides/secondary-storage.md @@ -0,0 +1,165 @@ + + +# Secondary storage + +> Supply a strongly consistent session and rate-limit store through an Effect layer. + +## Implement the complete storage contract + +```typescript +// storage.ts +import { + BetterAuthStorageError, + SecondaryStorage, +} from "@alchemy.run/better-auth"; +import { Redis } from "@upstash/redis"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; + +const incrementScript = ` +local count = redis.call("INCR", KEYS[1]) +if count == 1 then + redis.call("EXPIRE", KEYS[1], ARGV[1]) +end +return count +`; + +export const RedisStorage = Layer.effect( + SecondaryStorage, + Effect.gen(function* () { + const url = yield* Config.String("REDIS_REST_URL"); + const token = yield* Config.Redacted("REDIS_REST_TOKEN"); + const run = (operation: (redis: Redis) => Promise) => + Effect.tryPromise({ + try: (signal) => operation(new Redis({ + url, + token: Redacted.value(token), + automaticDeserialization: false, + enableAutoPipelining: false, + retry: false, + signal, + })), + catch: (cause) => new BetterAuthStorageError({ + message: "Secondary storage operation failed", + cause, + }), + }); + const keyFor = (key: string) => `better-auth:${key}`; + return SecondaryStorage.of({ + get: (key) => run((redis) => redis.get(keyFor(key))), + set: (key, value, ttlSeconds) => run((redis) => + ttlSeconds === undefined + ? redis.set(keyFor(key), value) + : redis.set(keyFor(key), value, { ex: ttlSeconds }), + ).pipe(Effect.asVoid), + delete: (key) => run((redis) => redis.del(keyFor(key))).pipe(Effect.asVoid), + getAndDelete: (key) => run((redis) => redis.getdel(keyFor(key))), + increment: (key, ttlSeconds) => run((redis) => + redis.eval<[number], number>(incrementScript, [keyFor(key)], [ttlSeconds]), + ), + }); + }), +); +``` + +The external backend must offer strongly consistent reads and writes through a Redis REST endpoint compatible with `@upstash/redis`, including `GETDEL` and `EVAL`. Use a dedicated database and credential for each environment; an eventually consistent read replica or Cloudflare KV does not satisfy this contract. + +Install `@upstash/redis` and provide the endpoint's `REDIS_REST_URL` and protected `REDIS_REST_TOKEN` at deployment. The wrapper exports the Effect service, not a built-in Redis implementation; this file supplies the adapter. + +Automatic JSON deserialization is disabled because Better Auth stores strings, including serialized JSON. Automatic retries are also disabled: replaying an increment or consume-once operation after an ambiguous network failure can change its meaning. + +## Preserve all five operations + +`get` returns a string or `null` only when a key is missing or expired. Connection and command failures become `BetterAuthStorageError`, never a missing value. + +`set` writes a value with its optional TTL in **seconds**, using one expiring command when a TTL is supplied. A separate write and expiry could leave a non-expiring credential behind if the second command failed. + +`delete` succeeds for an already-missing key but still reports backend failures. `getAndDelete` uses atomic `GETDEL`, so two concurrent consumers cannot both receive the credential. + +`increment` creates an absent counter at `1` with its initial TTL, or increments it without extending the original expiration. The Lua script executes both initialization and expiry as one server operation; rate-limit windows are fixed, not sliding. + +## Provide storage before constructing Better Auth + +```typescript +// worker.ts +import { BetterAuth } from "@alchemy.run/better-auth"; +import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { RedisStorage } from "./storage.ts"; + +const AuthDb = Cloudflare.D1.Database("AuthDb"); + +export default class AuthApi extends Cloudflare.Worker()( + "AuthApi", + { main: import.meta.url, compatibility: { flags: ["nodejs_compat"] } }, + Effect.gen(function* () { + const auth = yield* BetterAuth({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, + session: { storeSessionInDatabase: true }, + rateLimit: { + enabled: true, + storage: "secondary-storage", + window: 60, + max: 100, + }, + }); + return { fetch: auth.fetch }; + }).pipe( + Effect.provide(Layer.merge(CloudflareD1(AuthDb), RedisStorage)), + ), +) {} +``` + +The optional service must be present when `BetterAuth` is constructed; providing it only to a later request is too late. Users and linked accounts still need D1, while sessions, rate limiting, and some verification or OAuth state use secondary storage according to the configured options. + +`storeSessionInDatabase: true` retains a database copy but is not an outage fallback: Better Auth still reads sessions from secondary storage. Losing or evicting keys can sign users out, and changing stores is an operational migration rather than automatic session copying. + +The explicit limiter also runs in development, with a 60-second window and a maximum of 100 requests before endpoint-specific limits. It protects Better Auth's routes, not unrelated application endpoints. + +## Run the standalone host + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import AuthApi from "./worker.ts"; + +export default Alchemy.Stack( + "AuthStorage", + { providers: Cloudflare.providers(), state: Alchemy.localState() }, + Effect.gen(function* () { + const api = yield* AuthApi; + return { url: api.url }; + }), +); +``` + +Use Bun, matching `alchemy` and `@alchemy.run/better-auth` releases, Better Auth 1.7.5, Effect 4, and `kysely`, in addition to the Redis SDK. Configure Cloudflare credentials and the external storage inputs before deployment; this stack provisions D1 and a stable signing secret, not the Redis service. + +```sh +bunx alchemy deploy +``` + +Preserve the local Alchemy state and use the printed URL. A local run must use a separate storage database from production because the REST store is external, not emulated by `alchemy dev`. + +## Keep I/O request-scoped + +The layer captures only configuration; each operation creates its own REST client and request with Effect's abort signal. Auto-pipelining is disabled so no pending batch can span Worker events; the wrapper runs each operation in the captured `Alchemy.RuntimeContext`. + +A socket-based adapter must instead acquire disposable connections lazily within the request scope and release them there. Do not retain an open connection, response, or in-flight promise across Worker events. + +## Verify against the actual backend + +Send concurrent increments to one fresh test key and verify the results are exactly `1` through the number of calls. Increment again before expiry and verify expiration remains relative to the first increment, not the last. + +Write one value and issue concurrent `getAndDelete` calls: exactly one should receive the value and the others should receive `null`. Also verify seconds-based expiry, idempotent deletion, and failed Effects during an outage. + +Run these checks against the deployed backend rather than an in-memory substitute, then exercise real sign-in and sign-out. An atomic command does not compensate for eventually consistent reads elsewhere in the service. diff --git a/.agents/skills/alchemy/references/better-auth/sign-in-providers/_overview.md b/.agents/skills/alchemy/references/better-auth/sign-in-providers/_overview.md new file mode 100644 index 00000000..908d2745 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/sign-in-providers/_overview.md @@ -0,0 +1,47 @@ + + +# Sign-in providers + +> Choose a standalone sign-in walkthrough for your existing Alchemy project. + +Add authentication to an existing Alchemy project with one of the walkthroughs below. Each page introduces its own files; you do not need to copy an application from this overview or complete another provider guide first. + +## Choose a sign-in method + +- [Email and password](/better-auth/sign-in-providers/email-password): D1-backed registration, real email delivery through Resend, verification, and password recovery. +- [GitHub](/better-auth/sign-in-providers/github): an OAuth App with minimal profile and email scopes. +- [Google](/better-auth/sign-in-providers/google): a web OAuth client, consent-screen audience, and test users. +- [Microsoft](/better-auth/sign-in-providers/microsoft): an Entra registration with an explicit tenant. +- [Custom OAuth and OIDC](/better-auth/sign-in-providers/custom-oauth): a Keycloak client using discovery and ID-token verification. + +## Understand Alchemy's role + +The Auth service passes options directly to `BetterAuth`, reading server configuration through Effect `Config`. Its host supplies the database layer and forwards auth requests; Alchemy provisions the stable signing secret and runs supported database migrations automatically. + +The walkthroughs use a Cloudflare Worker and D1 with a [configured Cloudflare profile](/cloudflare/setup). Keep your Alchemy integration versions aligned and preserve the stack's state across deployments. + +## Keep the browser separate + +The browser uses `createAuthClient` from `better-auth/client`, not the server's Auth service. With the page and API on the same origin, the client uses `/api/auth` and Better Auth manages session cookies; private application endpoints must still check the session and authorization on the server. + +## Choose the actual origin + +Alchemy prints the development URL instead of requiring a fixed port. Use that actual origin wherever a provider requires a registered callback, and set `AUTH_BASE_URL` to the real HTTPS Worker origin in production, without a trailing slash or `/api/auth`. + +Setting `AUTH_BASE_URL` does not attach a domain or provision DNS. Keep the page and API on the same origin for these examples; a cross-origin application additionally needs explicit trusted origins, credentialed CORS, and an appropriate cookie policy—not disabled origin or CSRF checks. + +## Distinguish the two redirects + +A provider's redirect URI, such as `https://auth.example.com/api/auth/callback/github`, receives the authorization code on the server. The browser's `callbackURL`, such as `/`, selects the application page after success; email/password does not require an OAuth callback registration. + +Better Auth handles state, PKCE, code exchange, and session cookies. Leave callback failures on its built-in `/api/auth/error` page rather than adding a custom callback handler or an application error-query convention. + +## Keep credentials server-only + +Read public provider identifiers with `Config.String` and secrets with `Config.Redacted` during service construction. Use separate production credentials, keep environment files out of source control, and never put secrets in `VITE_*`, browser bundles, or logs—including token-bearing callback URLs. + +## Combine methods deliberately + +When adding another method, retain one Auth service, database, and session cookie; preserve the existing mail hooks and plugins, and add built-in OAuth providers to the same `socialProviders` object. Follow Better Auth's [account-linking guidance](https://www.better-auth.com/docs/concepts/users-accounts#account-linking) rather than merging users solely because their email addresses match. diff --git a/.agents/skills/alchemy/references/better-auth/sign-in-providers/custom-oauth.md b/.agents/skills/alchemy/references/better-auth/sign-in-providers/custom-oauth.md new file mode 100644 index 00000000..01423fa0 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/sign-in-providers/custom-oauth.md @@ -0,0 +1,361 @@ + + +# Custom OAuth and OIDC + +> Add Keycloak OIDC sign-in to an Alchemy Worker with D1, one small step at a time. + +Start in an existing Alchemy project with [Bun](https://bun.sh) and a [configured Cloudflare profile](/cloudflare/setup). This standalone walkthrough uses Keycloak and Better Auth 1.7.5's `genericOAuth`; all application files are introduced below. + +## Add the auth dependencies + +```sh +bun add @alchemy.run/better-auth better-auth@1.7.5 kysely +``` + +Use an `@alchemy.run/better-auth` release matching your installed Alchemy release. Keep your existing `alchemy` and `effect` dependencies. + +## Create the Auth service + +Create `src/auth.ts`: + +```typescript +import { BetterAuth } from "@alchemy.run/better-auth"; +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +const makeAuth = Effect.gen(function* () { + const baseURL = yield* Config.String("AUTH_BASE_URL").pipe(Config.option); + return yield* BetterAuth({ + basePath: "/api/auth", + baseURL: Option.getOrUndefined(baseURL), + account: { accountLinking: { disableImplicitLinking: true } }, + }); +}); + +export class Auth extends Context.Service< + Auth, + Effect.Success +>()("app/Auth") { + static readonly layer = Layer.effect(Auth, makeAuth); +} +``` + +The service keeps its options directly inside `BetterAuth` and reads Config during construction. Leaving `AUTH_BASE_URL` unset lets Better Auth use the incoming request's origin. + +## Start with OIDC disabled + +Apply this addition to `src/auth.ts`: + +```diff lang="typescript" +const makeAuth = Effect.gen(function* () { + const baseURL = yield* Config.String("AUTH_BASE_URL").pipe(Config.option); ++ const enabled = yield* Config.Boolean("OIDC_ENABLED").pipe( ++ Config.withDefault(false), ++ ); + return yield* BetterAuth({ +``` + +OIDC stays disabled until you know the local URL and register its callback. The next addition reads credentials and installs the plugin only when this switch is enabled. + +## Register the OIDC provider + +Apply both hunks to `src/auth.ts`, adding the imports and the inline plugin configuration: + +```diff lang="typescript" +import * as Option from "effect/Option"; ++import * as Redacted from "effect/Redacted"; ++import { genericOAuth } from "better-auth/plugins/generic-oauth"; +@@ + baseURL: Option.getOrUndefined(baseURL), ++ plugins: enabled ? [genericOAuth({ ++ config: [{ ++ providerId: "company", ++ clientId: yield* Config.String("OIDC_CLIENT_ID"), ++ clientSecret: Redacted.value(yield* Config.Redacted("OIDC_CLIENT_SECRET")), ++ discoveryUrl: yield* Config.String("OIDC_DISCOVERY_URL"), ++ scopes: ["openid", "profile", "email"], ++ pkce: true, ++ authentication: "basic", ++ requireIdTokenVerification: true, ++ requireEmailVerification: true, ++ disableProviderLogout: true, ++ }], ++ })] : [], + account: { accountLinking: { disableImplicitLinking: true } }, +``` + +In 1.7.5, `genericOAuth` registers a social provider named `company`, using `signIn.social` and `/api/auth/callback/company`, not the older `signIn.oauth2` flow. HTTP Basic client authentication matches Keycloak's client-secret token exchange, while required ID-token verification fails closed when discovery lacks usable verification metadata. + +## Keep application logout local + +`disableProviderLogout: true` makes sign-out end the application session without terminating a shared Keycloak SSO session. Keep it unless you deliberately configure provider-wide logout and its registered return URLs. + +## Declare the database + +Create `src/database.ts`: + +```typescript +import * as Cloudflare from "alchemy/Cloudflare"; + +export const AuthDb = Cloudflare.D1.Database("AuthDb"); +``` + +This declares the D1 database that will store users, accounts, and sessions. The same resource uses local D1 during `alchemy dev` and Cloudflare D1 when deployed. + +## Give Auth its database layer + +Create `src/auth-layer.ts`: + +```typescript +import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; +import * as Layer from "effect/Layer"; +import { Auth } from "./auth.ts"; +import { AuthDb } from "./database.ts"; + +export const AuthLive = Auth.layer.pipe( + Layer.provide(CloudflareD1(AuthDb)), +); +``` + +`CloudflareD1` supplies the database binding and automatic supported schema migrations. Alchemy also provisions the stable signing secret automatically; do not invent a manual secret for this example. + +## Host Auth in a Worker + +Create `src/worker.ts`: + +```typescript +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import { Auth } from "./auth.ts"; +import { AuthLive } from "./auth-layer.ts"; + +export default class AuthWorker extends Cloudflare.Worker()( + "AuthWorker", + { + main: import.meta.url, + compatibility: { flags: ["nodejs_compat"] }, + assets: { directory: "./public", runWorkerFirst: ["/api/*"] }, + }, + Effect.gen(function* () { + const auth = yield* Auth; + return { fetch: auth.fetch }; + }).pipe(Effect.provide(AuthLive)), +) {} +``` + +The Worker forwards API requests to Better Auth, including its session and callback endpoints. Static assets serve the browser page outside `/api/*`. + +## Define the stack + +Use this as `alchemy.run.ts` for the walkthrough: + +```typescript +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import AuthWorker from "./src/worker.ts"; + +export default Alchemy.Stack( + "CompanyAuth", + { providers: Cloudflare.providers(), state: Alchemy.localState() }, + Effect.gen(function* () { + const worker = yield* AuthWorker; + return { url: worker.url }; + }), +); +``` + +The output exposes the Worker's actual URL. Preserve the local state directory so subsequent runs retain resource identities and the signing secret. + +## Add the browser page + +Create `public/index.html`: + +```html + + + + + + Company sign-in + + +

Company sign-in

+ + + +

+ + + +``` + +The page provides three controls and a text-only status area. The browser bundle will be written to `public/client.js`. + +## Create the browser client + +Create `src/client.ts`: + +```typescript +import { createAuthClient } from "better-auth/client"; + +const authClient = createAuthClient(); +const status = document.getElementById("status")!; + +async function run(action: () => Promise) { + try { + status.textContent = await action(); + } catch { + status.textContent = "Could not reach the server. Try again."; + } +} +``` + +The client uses the page's origin and the default `/api/auth` path; no `genericOAuthClient` plugin is needed in 1.7.5. It imports no server Auth module or credentials. + +## Start company sign-in + +Append to `src/client.ts`: + +```typescript +document.getElementById("sign-in")!.onclick = () => void run(async () => { + const { data, error } = await authClient.signIn.social({ + provider: "company", + callbackURL: "/", + disableRedirect: true, + }); + if (error) return "Could not start company sign-in. Check the configuration."; + if (!data?.url) return "No authorization URL was returned."; + window.location.assign(data.url); + return "Redirecting to company sign-in…"; +}); +``` + +The provider ID matches the server's `company` registration. Better Auth returns the authorization URL and sends successful callbacks back to `/`. + +## Read the current session + +Append to `src/client.ts`: + +```typescript +async function readSession(): Promise { + const { data, error } = await authClient.getSession(); + if (error) return "Could not check your session. Try again."; + return data ? `Signed in as ${data.user.name}.` : "Not signed in."; +} + +document.getElementById("read-session")!.onclick = () => void run(readSession); +void run(readSession); +``` + +The page checks the session on load, including after the OAuth callback. The button repeats that lookup without confusing a failed request with an anonymous session. + +## Sign out + +Append to `src/client.ts`: + +```typescript +document.getElementById("sign-out")!.onclick = () => void run(async () => { + const { error } = await authClient.signOut(); + return error ? "Could not sign out. Try again." : "Signed out."; +}); +``` + +This ends the application session. Because provider logout is disabled, a subsequent sign-in may reuse the user's existing Keycloak SSO session. + +## Bundle the browser client + +```sh +bun build ./src/client.ts --target browser --outdir ./public +``` + +The final client is the initial `src/client.ts` block followed by the three append blocks, in order. The final `src/auth.ts` is its initial module with both labeled diffs applied; every other module above is complete as shown. + +## Discover the local origin + +```sh +bunx alchemy dev --stage company-guide +``` + +Run without provider credentials or `OIDC_ENABLED=true` for this first start; Alchemy allocates a port and prints the actual origin. Keep OIDC disabled until the registration below is ready, and rebuild the browser bundle whenever you edit `src/client.ts`. + +## Create the Keycloak client + +In Keycloak, select your application realm rather than the administration realm, open **Clients → Create client**, choose **OpenID Connect**, and set a client ID such as `alchemy-auth`. Enable **Client authentication** and **Standard flow**, leaving implicit flow and direct access grants disabled. + +## Register the callback + +```text +/api/auth/callback/company +``` + +In **Login settings**, set the home URL to the printed origin and add that origin plus this path to **Valid redirect URIs**, without wildcards. Save the client and copy its secret from **Credentials** for the server-side token exchange. + +## Configure identity claims + +Require S256 PKCE in the client's advanced settings where available, and ensure the `profile` and `email` scopes supply `sub`, `name`, `email`, and `email_verified`. Configure real realm email delivery and verification, and use verified realm users rather than marking arbitrary addresses verified. + +Keycloak's [server administration guide](https://www.keycloak.org/docs/latest/server_admin/#_oidc_clients) covers client configuration. Its [OIDC guide](https://www.keycloak.org/securing-apps/oidc-layers) describes discovery endpoints. + +## Supply server credentials + +Create `.env.local.example`, then copy it to a gitignored `.env.local` and replace the placeholder values: + +```dotenv +OIDC_ENABLED=true +OIDC_CLIENT_ID=alchemy-auth +OIDC_CLIENT_SECRET=replace-with-your-client-secret +OIDC_DISCOVERY_URL=https://identity.example.com/realms/my-app/.well-known/openid-configuration +# Leave AUTH_BASE_URL unset for local request-origin discovery. +``` + +Discovery must be reachable from the Worker and publish the expected issuer, authorization/token endpoints, signing algorithms, and `jwks_uri`; use the application realm's real URL. These server-side Config values must stay out of `VITE_*`, browser bundles, source control, and logs. + +## Enable OIDC locally + +```sh +bunx alchemy dev --stage company-guide --env-file .env.local +``` + +Stop the first dev process before running this command, and update Keycloak's callback if the newly printed origin changes. Open that origin, select **Sign in with company**, then use **Read session** and **Sign out** to exercise the roundtrip. + +## Set the production origin + +Create `.env.production.example` as a template for your private production configuration: + +```dotenv +AUTH_BASE_URL=https://auth.example.com +OIDC_ENABLED=true +OIDC_CLIENT_ID=replace-with-production-client-id +OIDC_CLIENT_SECRET=replace-with-production-client-secret +OIDC_DISCOVERY_URL=https://identity.example.com/realms/production/.well-known/openid-configuration +``` + +Replace the example origin with the actual HTTPS Worker origin, without a trailing slash or `/api/auth`; `baseURL` does not attach a domain to the Worker. Use separate production clients or realms, register that origin plus `/api/auth/callback/company`, and supply these values through your deployment's server configuration. + +## Preserve provider identity + +`company` determines both the callback path and stored provider identity: do not rename it or point it at an unrelated issuer once accounts exist. Discovery-based OIDC uses the provider profile's `sub` as the account subject, not a mutable email address, and this configuration requires verified ID tokens with nonce binding enabled. + +Keep implicit linking disabled, verified-email checks, and the same-email restriction if you later add account linking. Follow Better Auth's [account-linking guidance](https://www.better-auth.com/docs/concepts/users-accounts#account-linking) rather than adding `company` to `trustedProviders` or merging rows to bypass ownership checks. + +## Adapt another provider + +Edit the inline `genericOAuth` configuration in `src/auth.ts`, preserving any other plugins when integrating into a larger application. For plain OAuth, use reviewed `authorizationUrl`, `tokenUrl`, and `userInfoUrl` settings instead of OIDC discovery, and verify the vendor's token authentication, PKCE, identity, and email-trust requirements. + +Plain OAuth defaults to the raw profile's immutable `id`; nonstandard fields require an `accountSubject` resolver and possibly `getUserInfo` or `mapProfileToUser`. Consult the upstream [Generic OAuth documentation](https://www.better-auth.com/docs/plugins/generic-oauth) rather than merely removing ID-token verification from this OIDC example. + +## Diagnose callback failures + +Better Auth displays callback failures on its built-in `/api/auth/error` page; no custom application error query is needed. Keep ID-token verification, nonce binding, state, origin, and CSRF checks enabled. + +For an unknown provider, check discovery reachability and issuer/JWKS metadata; for callback mismatches, compare the exact `/api/auth/callback/company` registration. For token-exchange failures, compare the secret and authentication method, then start a fresh sign-in instead of replaying codes or logging tokens and full callback URLs. + +## Resolve email-verification failures + +`requireEmailVerification` checks local user verification and can leave a created or linked account without a session. For `email_not_verified`, review both issuer and local verification state: an existing unverified local user needs a real [email-verification flow](https://www.better-auth.com/docs/concepts/email), not a database edit to mark the user verified. diff --git a/.agents/skills/alchemy/references/better-auth/sign-in-providers/email-password.md b/.agents/skills/alchemy/references/better-auth/sign-in-providers/email-password.md new file mode 100644 index 00000000..d05c3636 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/sign-in-providers/email-password.md @@ -0,0 +1,549 @@ + + +# Email and password + +> Add D1-backed registration, real email verification, and password recovery to an Alchemy project. + +Start with an existing Alchemy project and a [configured Cloudflare profile](/cloudflare/setup). This standalone walkthrough uses a Worker, D1, and Resend; every local module and browser file is introduced here. + +Create each named file when it first appears, append snippets only where instructed, and apply the one diff to the already-introduced `src/auth.ts`. Complete all steps before starting the application; no tutorial files or other provider pages are required. + +## Add the authentication dependencies + +```sh +bun add @alchemy.run/better-auth better-auth@1.7.5 kysely +``` + +Keep `@alchemy.run/better-auth` aligned with your existing Alchemy release. + +## Configure the mail sender + +Create `.env.local`: + +```dotenv +RESEND_API_KEY=replace-with-your-sending-key +AUTH_EMAIL_FROM="Auth " +``` + +In [Resend](https://resend.com/docs/api-reference/emails/send-email), verify a domain through DNS, create a sending key restricted to that domain, and replace both values with real credentials. Use a verified sender rather than a restricted sandbox sender so verification and recovery reach real inboxes. + +## Keep environment files private + +Append these entries to your existing `.gitignore` if they are not already covered: + +```text +.env.local +.env.production +``` + +Use separate production credentials, never browser-prefixed variables such as `VITE_RESEND_API_KEY`. Keep keys, passwords, mail bodies, and token-bearing URLs out of source control and logs. + +## Read mail configuration with Config + +Create `src/mail.ts`: + +```typescript +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; + +const mailConfig = Effect.gen(function* () { + const from = yield* Config.String("AUTH_EMAIL_FROM"); + const apiKey = yield* Config.Redacted("RESEND_API_KEY"); + const http = yield* HttpClient.HttpClient; + return { from, apiKey, http }; +}); +``` + +Alchemy resolves this server configuration when constructing the Auth service. The host will supply an HTTP client, and the API key stays redacted until the request transport uses it. + +## Send mail through Resend + +Append to `src/mail.ts`: + +```typescript +export const makeSendMail = mailConfig.pipe( + Effect.map(({ from, apiKey, http }) => + (to: string, subject: string, text: string) => + http.execute( + HttpClientRequest.post("https://api.resend.com/emails").pipe( + HttpClientRequest.bearerToken(apiKey), + HttpClientRequest.bodyJsonUnsafe({ from, to: [to], subject, text }), + ), + ).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.text), + Effect.asVoid, + Effect.timeout("10 seconds"), + Effect.mapError(() => new Error("Email delivery failed")), + Effect.scoped, + Effect.runPromise, + ), + ), +); +``` + +Each call sends one bounded request and rejects on a non-success response, without retaining an in-flight request between Worker events. `Effect.runPromise` bridges the mail operation to Better Auth's Promise-based hooks, while errors avoid exposing provider responses or credentials. + +## Construct verified email authentication + +Create `src/auth.ts`: + +```typescript +import { BetterAuth } from "@alchemy.run/better-auth"; +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { makeSendMail } from "./mail.ts"; + +const makeAuth = Effect.gen(function* () { + const baseURL = yield* Config.String("AUTH_BASE_URL").pipe(Config.option); + const sendMail = yield* makeSendMail; + return yield* BetterAuth({ + basePath: "/api/auth", + baseURL: Option.getOrUndefined(baseURL), + emailAndPassword: { enabled: true, requireEmailVerification: true }, + emailVerification: { + sendOnSignUp: true, + autoSignInAfterVerification: false, + sendVerificationEmail: ({ user, url }) => + sendMail(user.email, "Verify your email", `Verify your email: ${url}`), + }, + }); +}); +``` + +Options live directly in `BetterAuth`, with verification required before sign-in and generated links sent unchanged as plain text. Leave `AUTH_BASE_URL` unset locally to derive the origin from the request; the production override comes later. + +## Enable password recovery + +Apply this diff to `src/auth.ts`, replacing its `emailAndPassword` property: + +```diff lang="typescript" +- emailAndPassword: { enabled: true, requireEmailVerification: true }, ++ emailAndPassword: { ++ enabled: true, ++ requireEmailVerification: true, ++ revokeSessionsOnPasswordReset: true, ++ sendResetPassword: ({ user, url }) => ++ sendMail(user.email, "Reset your password", `Reset your password: ${url}`), ++ }, +``` + +Recovery sends Better Auth's generated reset link and revokes existing sessions after a successful password reset. Keep server-side password validation and rate limiting enabled; see upstream [email/password policy](https://www.better-auth.com/docs/authentication/email-password) for additional controls. + +## Expose Auth as an Effect service + +Append to `src/auth.ts`: + +```typescript +export class Auth extends Context.Service< + Auth, + Effect.Success +>()("app/Auth") { + static readonly layer = Layer.effect(Auth, makeAuth); +} +``` + +The service retains the inferred Better Auth API while leaving its database and HTTP dependencies for the host to supply. Browser code will not import this module. + +## Declare the database + +Create `src/database.ts`: + +```typescript +import * as Cloudflare from "alchemy/Cloudflare"; + +export const AuthDb = Cloudflare.D1.Database("AuthDb"); +``` + +This declares the D1 resource without embedding a database client in the Auth options. The Worker will connect it through `CloudflareD1`. + +## Forward authentication requests + +Create `src/handler.ts`: + +```typescript +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; +import { Auth } from "./auth.ts"; + +export const handlers = Effect.gen(function* () { + const auth = yield* Auth; + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const path = request.url.split("?")[0]; + if (path === "/api/auth" || path.startsWith("/api/auth/")) { + return yield* auth.fetch; + } + return HttpServerResponse.text("Not found", { status: 404 }); + }), + }; +}); +``` + +Pass auth routes through unchanged so Better Auth owns verification, reset endpoints, and session cookies. Its built-in `/api/auth/error` page remains available; there is no custom callback handler or `authError` query convention. + +## Supply the Worker dependencies + +Create `src/worker.ts`: + +```typescript +import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import { Auth } from "./auth.ts"; +import { AuthDb } from "./database.ts"; +import { handlers } from "./handler.ts"; + +export default class AuthWorker extends Cloudflare.Worker()( + "AuthWorker", + { + main: import.meta.url, + compatibility: { flags: ["nodejs_compat"] }, + assets: { directory: "./public", runWorkerFirst: ["/api/*"] }, + }, + handlers.pipe(Effect.provide(Auth.layer.pipe( + Layer.provide(CloudflareD1(AuthDb)), + Layer.provide(FetchHttpClient.layer), + ))), +) {} +``` + +The host supplies D1 and the HTTP transport while serving the browser assets alongside `/api/auth`. Alchemy automatically provisions a stable signing secret and runs the supported D1 migrations, including against the local D1 simulator during development—no manual secret or migration command is needed. + +## Define the stack + +Use this `alchemy.run.ts` for the walkthrough, or add `AuthWorker` to your existing stack while retaining its state backend: + +```typescript +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import AuthWorker from "./src/worker.ts"; + +export default Alchemy.Stack( + "EmailAuth", + { providers: Cloudflare.providers(), state: Alchemy.localState() }, + Effect.gen(function* () { + const worker = yield* AuthWorker; + return { url: worker.url }; + }), +); +``` + +Preserve the stack's state between runs so resource identities and the signing secret remain stable. If using this local state backend, protect and retain its state directory rather than treating it as disposable output. + +## Create the browser form + +Create `public/index.html`: + +```html + + + + + + + Email sign-in + + +

Email sign-in

+
+

+

+

+
+

+ + + +``` + +The browser module will add action buttons; registration uses all three fields, sign-in uses email and password, and recovery uses the password field for the replacement password. These inputs mirror the default password length range, but the server remains authoritative. + +## Protect pages containing recovery links + +Create `public/_headers`: + +```text +/* + Referrer-Policy: no-referrer + Cache-Control: no-store +``` + +Cloudflare applies these headers to static assets, not Worker-generated responses. Keep this page free of third-party analytics and redact token-bearing URLs in access logs, including requests handled by the Worker. + +## Connect the browser client + +Create `src/client.ts`: + +```typescript +import { createAuthClient } from "better-auth/client"; + +const authClient = createAuthClient(); +const form = document.querySelector("#credentials")!; +const nameInput = document.querySelector("#name")!; +const emailInput = document.querySelector("#email")!; +const passwordInput = document.querySelector("#password")!; +const status = document.querySelector("#status")!; +form.addEventListener("submit", (event) => event.preventDefault()); +``` + +`createAuthClient()` uses this page's origin and `/api/auth`, with no server imports or credentials. Every remaining browser snippet is appended to this same file in page order. + +## Display action results safely + +Append to `src/client.ts`: + +```typescript +async function run(action: () => Promise) { + const buttons = document.querySelectorAll("button"); + buttons.forEach((element) => { element.disabled = true; }); + try { + status.textContent = await action(); + } catch { + status.textContent = "Could not reach the server. Try again."; + } finally { + passwordInput.value = ""; + buttons.forEach((element) => { element.disabled = false; }); + } +} +``` + +Render messages as text, clear the password after each action, and prevent overlapping button submissions. Never store passwords in browser storage. + +## Add a reusable action button + +Append to `src/client.ts`: + +```typescript +function button(label: string, action: () => Promise) { + const element = document.createElement("button"); + element.type = "button"; + element.textContent = label; + element.addEventListener("click", () => void run(action)); + document.body.append(element); +} +``` + +Each button below runs through the same error display and password cleanup. The HTML page needs no inline event handlers. + +## Read the current session + +Append to `src/client.ts`: + +```typescript +async function readAccount(): Promise { + const { data, error } = await authClient.getSession(); + if (error) return "Could not check your session. Try again."; + return data ? `Signed in as ${data.user.name}.` : "Verify your email, then sign in."; +} +``` + +Read the actual session rather than assuming a successful request authenticated the user. This is browser feedback, not authorization: private server endpoints must independently check the session and application permissions. + +## Register an account + +Append to `src/client.ts`: + +```typescript +button("Register", async () => { + const { error } = await authClient.signUp.email({ + name: nameInput.value, + email: emailInput.value, + password: passwordInput.value, + callbackURL: "/", + }); + return error + ? "Could not register. Check your details and try again." + : "Check your inbox to verify your email, then sign in."; +}); +``` + +Registration sends the verification message through Resend. The link passes through Better Auth's verification endpoint and returns to `/`; users must then sign in because automatic sign-in after verification is disabled. + +## Sign in with verified credentials + +Append to `src/client.ts`: + +```typescript +button("Sign in", async () => { + const { data, error } = await authClient.signIn.email({ + email: emailInput.value, + password: passwordInput.value, + }); + if (error?.code === "EMAIL_NOT_VERIFIED") return "Verify your email first."; + if (error) return "Could not sign in. Check your email and password."; + if (!data?.user) return "Sign-in did not finish. Try again."; + return readAccount(); +}); +``` + +The server refuses unverified sign-ins and issues the session cookie after successful authentication. Delivery failures are not a reason to disable verification. + +## Request another verification message + +Append to `src/client.ts`: + +```typescript +let nextVerificationRequest = 0; +button("Resend verification", async () => { + if (Date.now() < nextVerificationRequest) return "Wait a minute before retrying."; + nextVerificationRequest = Date.now() + 60_000; + const { error } = await authClient.sendVerificationEmail({ + email: emailInput.value, + callbackURL: "/", + }); + return error + ? "Could not request verification. Try again later." + : "If this address needs verification, check its inbox."; +}); +``` + +Use a neutral response instead of displaying whether the address exists. This cooldown is only a UI convenience; keep Better Auth's server [rate limiting](https://www.better-auth.com/docs/concepts/rate-limit) enabled. + +## Request a password reset + +Append to `src/client.ts`: + +```typescript +button("Request password reset", async () => { + const { error } = await authClient.requestPasswordReset({ + email: emailInput.value, + redirectTo: "/", + }); + return error + ? "Could not request a reset. Try again later." + : "If this email has an account, check its inbox for a reset link."; +}); +``` + +Better Auth sends a single-use reset link without making the UI disclose account existence. The link first reaches `/api/auth/reset-password/`, then returns to this page with the reset token. + +## Receive the recovery token + +Append to `src/client.ts`: + +```typescript +const query = new URLSearchParams(window.location.search); +let resetToken = query.get("token"); +const invalidEmailLink = query.has("error"); +window.history.replaceState(null, "", window.location.pathname); +``` + +Capture Better Auth's standard email-link parameters, then remove them from the address bar; the token stays only in memory, so reloading requires reopening the email link. This handles the email flow's own redirect parameters, not a custom OAuth callback error page. + +## Save the replacement password + +Append to `src/client.ts`: + +```typescript +button("Save new password", async () => { + if (invalidEmailLink || !resetToken) return "Open a new password reset link first."; + const { error } = await authClient.resetPassword({ + token: resetToken, + newPassword: passwordInput.value, + }); + if (error) return "Could not reset your password. Request a new link."; + resetToken = null; + return "Password reset. Sign in with your new password."; +}); +``` + +After opening the email link, enter the new password and choose **Save new password**. The configured recovery policy invalidates existing sessions; sign in again with the new password. + +## Add a session check + +Append to `src/client.ts`: + +```typescript +button("Check session", readAccount); +``` + +This control lets the user check the session again after signing in or resetting a password. + +## Sign out + +Append to `src/client.ts`: + +```typescript +button("Sign out", async () => { + const { error } = await authClient.signOut(); + return error ? "Could not sign out. Try again." : "Signed out."; +}); +``` + +The same-origin client ends the session without reading session cookies directly from JavaScript. + +## Show the initial page state + +Append this final block to `src/client.ts`: + +```typescript +if (invalidEmailLink) { + status.textContent = "This email link is invalid or expired. Request a new one."; +} else if (resetToken) { + status.textContent = "Enter your new password and choose Save new password."; +} else { + void run(readAccount); +} +``` + +An incoming reset link prompts for a new password rather than being mistaken for a signed-in session. Ordinary visits check the session immediately. + +## Bundle the browser module + +Run from the project root: + +```sh +bun build ./src/client.ts --target browser --outdir ./public +``` + +This produces the `/client.js` referenced by the HTML page. Re-run it whenever you change the browser module; it does not bundle `src/auth.ts` or the mail credentials into the client. + +## Start local development + +```sh +bunx alchemy dev --stage email-guide --env-file .env.local +``` + +Open the URL Alchemy prints, register with an inbox you control, and keep the server running while following verification or recovery links. No fixed port or OAuth application is required; if the origin changes, request fresh messages instead of reusing old links. + +## Check the complete email flow + +Register, open the verification email, return to the page, and sign in; **Check session** should now show your name. Request a password reset, open its email, save a replacement password, and confirm that the old session and password no longer work before signing in again. + +For missing mail, inspect Resend's delivery status without logging message bodies or generated links. Also try an expired or already-used reset link and confirm it cannot reset the password again. + +## Configure the production origin + +Create a separate, gitignored `.env.production`: + +```dotenv +AUTH_BASE_URL=https://auth.your-domain.example +RESEND_API_KEY=replace-with-your-production-sending-key +AUTH_EMAIL_FROM="Auth " +``` + +Replace the origin with the actual HTTPS Worker origin, without a trailing slash or `/api/auth`, and use real production mail credentials. `AUTH_BASE_URL` does not provision DNS or attach a domain: configure the hostname on the Worker separately, or use its actual `workers.dev` origin. + +## Deploy with production configuration + +After bundling the browser module, deploy from the project root: + +```sh +bunx alchemy deploy --stage production --env-file .env.production +``` + +Keep production state and credentials separate from development, and test delivery against the deployed origin. This walkthrough serves the page and API together; a separate browser origin requires explicit trusted origins, credentialed CORS, and compatible cookie settings—never disable origin or CSRF checks to work around a mismatch. diff --git a/.agents/skills/alchemy/references/better-auth/sign-in-providers/github.md b/.agents/skills/alchemy/references/better-auth/sign-in-providers/github.md new file mode 100644 index 00000000..57ab3415 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/sign-in-providers/github.md @@ -0,0 +1,344 @@ + + +# GitHub + +> Add GitHub sign-in to a D1-backed Alchemy Worker, one step at a time. + +Start in an existing Alchemy project with [Bun](https://bun.sh) and a [configured Cloudflare profile](/cloudflare/setup). This page supplies every application file; paths are relative to your project root. + +## Add the auth dependencies + +```sh +bun add @alchemy.run/better-auth better-auth@1.7.5 kysely +``` + +Use an integration release matching your installed Alchemy version. + +## Read the GitHub credentials + +Create `src/credentials.ts`: + +```typescript +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; + +export const githubCredentials = Effect.gen(function* () { + const enabled = yield* Config.Boolean("GITHUB_ENABLED").pipe( + Config.withDefault(false), + ); + if (!enabled) return undefined; + + return { + clientId: yield* Config.String("GITHUB_CLIENT_ID"), + clientSecret: Redacted.value( + yield* Config.Redacted("GITHUB_CLIENT_SECRET"), + ), + }; +}); +``` + +GitHub starts disabled so the first run can discover the local URL without credentials. When enabled, this server-only Effect reads both credentials and unwraps the redacted secret for Better Auth. + +## Construct Better Auth + +Create `src/auth.ts`: + +```typescript +import { BetterAuth } from "@alchemy.run/better-auth"; +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { githubCredentials } from "./credentials.ts"; + +const makeAuth = Effect.gen(function* () { + const baseURL = yield* Config.String("AUTH_BASE_URL").pipe(Config.option); + const github = yield* githubCredentials; + return yield* BetterAuth({ + basePath: "/api/auth", + baseURL: Option.getOrUndefined(baseURL), + socialProviders: github ? { github } : {}, + account: { accountLinking: { disableImplicitLinking: true } }, + }); +}); +``` + +Options stay directly in `BetterAuth`; without `AUTH_BASE_URL`, it derives the origin from the request. The `Context` and `Layer` imports are used by the next addition to this same file. + +## Expose the Auth service + +Append to `src/auth.ts`: + +```typescript +export class Auth extends Context.Service< + Auth, + Effect.Success +>()("app/Auth") { + static readonly layer = Layer.effect(Auth, makeAuth); +} +``` + +The service exposes the inferred Better Auth instance to the Worker. Its layer constructs the instance and reads Config during initialization, not in browser code. + +## Declare the database + +Create `src/database.ts`: + +```typescript +import * as Cloudflare from "alchemy/Cloudflare"; + +export const AuthDb = Cloudflare.D1.Database("AuthDb"); +``` + +This declares the D1 resource used for users, accounts, and sessions. Alchemy supplies its local implementation during `alchemy dev`. + +## Supply the database layer + +Create `src/auth-live.ts`: + +```typescript +import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; +import * as Layer from "effect/Layer"; +import { Auth } from "./auth.ts"; +import { AuthDb } from "./database.ts"; + +export const AuthLive = Auth.layer.pipe( + Layer.provide(CloudflareD1(AuthDb)), +); +``` + +`CloudflareD1` supplies the native Worker binding and supported automatic schema migrations. Alchemy also provisions Better Auth's stable signing secret automatically; do not create a manual secret or migration script. + +## Host the auth endpoints + +Create `src/worker.ts`: + +```typescript +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import { Auth } from "./auth.ts"; +import { AuthLive } from "./auth-live.ts"; + +export default class AuthWorker extends Cloudflare.Worker()( + "AuthWorker", + { + main: import.meta.url, + compatibility: { flags: ["nodejs_compat"] }, + assets: { directory: "./public", runWorkerFirst: ["/api/*"] }, + }, + Effect.gen(function* () { + const auth = yield* Auth; + return { fetch: auth.fetch }; + }).pipe(Effect.provide(AuthLive)), +) {} +``` + +Requests under `/api/*` reach Better Auth, including its callback and built-in error page. The static asset handler serves the HTML and browser bundle from `public`. + +## Define the stack + +Use this as `alchemy.run.ts`: + +```typescript +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import AuthWorker from "./src/worker.ts"; + +export default Alchemy.Stack( + "GitHubAuth", + { providers: Cloudflare.providers(), state: Alchemy.localState() }, + Effect.gen(function* () { + const worker = yield* AuthWorker; + return { url: worker.url }; + }), +); +``` + +This standalone stack exports the Worker's URL. Preserve the local state directory and use the same stage across restarts so resource identities and the signing secret remain stable. + +## Add the HTML controls + +Create `public/index.html`: + +```html + + + + + + GitHub sign-in + + +

GitHub sign-in

+ + + +

+ + + +``` + +These three controls will sign in, read the current session, and sign out. The module script runs after the document is parsed, so the client can find the controls immediately. + +## Initialize the browser client + +Create `src/client.ts`: + +```typescript +import { createAuthClient } from "better-auth/client"; + +const authClient = createAuthClient(); +const status = document.querySelector("#status")!; + +async function run(action: () => Promise) { + try { + status.textContent = await action(); + } catch { + status.textContent = "Could not reach the server. Try again."; + } +} +``` + +The client uses the current origin and `/api/auth`, with no server imports or credentials. `run` displays results as text and handles network failures for each action below. + +## Start GitHub sign-in + +Append to `src/client.ts`: + +```typescript +async function signIn(): Promise { + const { data, error } = await authClient.signIn.social({ + provider: "github", + callbackURL: "/", + disableRedirect: true, + }); + if (error) return "Could not start GitHub sign-in. Check your settings."; + if (!data?.url) return "No authorization URL was returned."; + window.location.assign(data.url); + return "Redirecting to GitHub…"; +} + +document.querySelector("#sign-in")! + .addEventListener("click", () => void run(signIn)); +``` + +The button requests an authorization URL and navigates to GitHub only if initiation succeeds. `callbackURL: "/"` is the successful return page, not the provider's registered callback endpoint. + +## Read the session + +Append to `src/client.ts`: + +```typescript +async function readSession(): Promise { + const { data, error } = await authClient.getSession(); + if (error) return "Could not read your session. Try again."; + return data ? `Signed in as ${data.user.name}` : "Not signed in."; +} + +document.querySelector("#session")! + .addEventListener("click", () => void run(readSession)); +void run(readSession); +``` + +The page checks the actual session on load, including after the OAuth redirect. The Read session button repeats that check; a successful redirect alone is not proof of authentication. + +## Sign out + +Append to `src/client.ts`: + +```typescript +async function signOut(): Promise { + const { error } = await authClient.signOut(); + return error ? "Could not sign out. Try again." : "Signed out."; +} + +document.querySelector("#sign-out")! + .addEventListener("click", () => void run(signOut)); +``` + +This asks Better Auth to end the session and reports the result. For account linking beyond this demo, use the upstream [account linking guide](https://www.better-auth.com/docs/concepts/users-accounts#account-linking) rather than bypassing verified-email checks or merging rows. + +## Bundle the browser module + +```sh +bun build ./src/client.ts --target browser --outdir ./public +``` + +This creates `public/client.js` for the HTML script tag. Rebuild it after editing the client. + +## Discover the local origin + +```sh +bunx alchemy dev --stage github-guide +``` + +Alchemy allocates an available port and prints the URL; open that exact URL, but leave sign-in alone until GitHub is configured. Keep `GITHUB_ENABLED` and `AUTH_BASE_URL` unset on this first run so OAuth remains disabled and no origin is guessed. + +## Register the OAuth App + +```text +/api/auth/callback/github +``` + +In GitHub **Settings → Developer settings → OAuth Apps → New OAuth App**, use the printed origin for **Homepage URL** and append the path above for **Authorization callback URL**. Leave device flow disabled for this browser authorization-code flow; GitHub's [registration guide](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) covers the form. + +## Save the server credentials + +Create `.env.local`: + +```dotenv +GITHUB_ENABLED=true +GITHUB_CLIENT_ID=replace-with-your-client-id +GITHUB_CLIENT_SECRET=replace-with-your-client-secret +``` + +Copy the OAuth App's **Client ID**, generate a **Client secret**, and replace the template values. Keep this file gitignored; never put the secret in `VITE_*`, browser code, source control, or logs. + +## Enable GitHub locally + +Stop the first dev process, then run: + +```sh +bunx alchemy dev --stage github-guide --env-file .env.local +``` + +If the printed origin changed, update the OAuth App's homepage and exact callback URL before signing in. An optional `AUTH_BASE_URL` override must equal that origin without a trailing slash or `/api/auth`; leaving it unset avoids pinning an old dev port. + +## Try the sign-in flow + +Open the current Worker URL, select **Sign in with GitHub**, and authorize the app. Back on `/`, check the displayed session, then select **Sign out** and **Read session** to confirm it is gone. + +## Set a production origin + +Use this separate `.env.production` template: + +```dotenv +AUTH_BASE_URL=https://auth.example.com +GITHUB_ENABLED=true +GITHUB_CLIENT_ID=replace-with-production-client-id +GITHUB_CLIENT_SECRET=replace-with-production-client-secret +``` + +Replace the origin with the actual HTTPS Worker origin and use a separate OAuth App with callback `https://auth.example.com/api/auth/callback/github`, adjusted to match. Keep the file gitignored and load these values for production; `AUTH_BASE_URL` does not attach a custom domain to the Worker. + +## Keep GitHub permissions minimal + +Better Auth requests `read:user` and `user:email` by default, including private email verification status; ordinary sign-in needs neither repository scopes nor a personal access token. Resolve missing or unverified email on GitHub instead of weakening verification. + +## Diagnose callback failures + +Better Auth displays callback failures on its built-in `/api/auth/error` page; no custom error query flag is needed. Keep state, PKCE, origin, and CSRF checks enabled. + +## Correct a callback mismatch + +Compare the exact scheme, hostname, port, and callback path against the OAuth App and any `AUTH_BASE_URL` override. Restart sign-in after correcting settings instead of replaying an authorization code. + +## Check rejected credentials + +Check that the client ID and secret belong to the same OAuth App and whether organization OAuth restrictions require approval. Never log full callback URLs, provider tokens, or client secrets. diff --git a/.agents/skills/alchemy/references/better-auth/sign-in-providers/google.md b/.agents/skills/alchemy/references/better-auth/sign-in-providers/google.md new file mode 100644 index 00000000..825e8f3c --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/sign-in-providers/google.md @@ -0,0 +1,360 @@ + + +# Google + +> Add Google sign-in to a D1-backed Alchemy Worker, one step at a time. + +Start in an existing Alchemy project with [Bun](https://bun.sh) and a [configured Cloudflare profile](/cloudflare/setup). This page supplies every application file and uses Google's server-side authorization-code flow, not the Google browser SDK. + +## Add the auth dependencies + +```sh +bun add @alchemy.run/better-auth better-auth@1.7.5 kysely +``` + +Use an integration release matching your installed Alchemy version. All file paths below are relative to your project root. + +## Read the Google credentials + +Create `src/credentials.ts`: + +```typescript +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; + +export const googleCredentials = Effect.gen(function* () { + const enabled = yield* Config.Boolean("GOOGLE_ENABLED").pipe( + Config.withDefault(false), + ); + if (!enabled) return undefined; + + return { + clientId: yield* Config.String("GOOGLE_CLIENT_ID"), + clientSecret: Redacted.value( + yield* Config.Redacted("GOOGLE_CLIENT_SECRET"), + ), + }; +}); +``` + +Google starts disabled so the first run can discover the local URL without credentials. When enabled, this server-only Effect reads both credentials and unwraps the redacted secret for Better Auth. + +## Construct Better Auth + +Create `src/auth.ts`: + +```typescript +import { BetterAuth } from "@alchemy.run/better-auth"; +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { googleCredentials } from "./credentials.ts"; + +const makeAuth = Effect.gen(function* () { + const baseURL = yield* Config.String("AUTH_BASE_URL").pipe(Config.option); + const google = yield* googleCredentials; + return yield* BetterAuth({ + basePath: "/api/auth", + baseURL: Option.getOrUndefined(baseURL), + socialProviders: google ? { google } : {}, + account: { accountLinking: { disableImplicitLinking: true } }, + }); +}); +``` + +Options stay directly in `BetterAuth`; without `AUTH_BASE_URL`, it derives the origin from the request. The `Context` and `Layer` imports are used by the next addition to this same file. + +## Expose the Auth service + +Append to `src/auth.ts`: + +```typescript +export class Auth extends Context.Service< + Auth, + Effect.Success +>()("app/Auth") { + static readonly layer = Layer.effect(Auth, makeAuth); +} +``` + +The service exposes the inferred Better Auth instance to the Worker. Its layer constructs the instance and reads Config during initialization, not in browser code. + +## Declare the database + +Create `src/database.ts`: + +```typescript +import * as Cloudflare from "alchemy/Cloudflare"; + +export const AuthDb = Cloudflare.D1.Database("AuthDb"); +``` + +This declares the D1 resource used for users, accounts, and sessions. Alchemy supplies its local implementation during `alchemy dev`. + +## Supply the database layer + +Create `src/auth-live.ts`: + +```typescript +import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; +import * as Layer from "effect/Layer"; +import { Auth } from "./auth.ts"; +import { AuthDb } from "./database.ts"; + +export const AuthLive = Auth.layer.pipe( + Layer.provide(CloudflareD1(AuthDb)), +); +``` + +`CloudflareD1` supplies the native Worker binding and supported automatic schema migrations. Alchemy also provisions Better Auth's stable signing secret automatically; do not create a manual secret or migration script. + +## Host the auth endpoints + +Create `src/worker.ts`: + +```typescript +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import { Auth } from "./auth.ts"; +import { AuthLive } from "./auth-live.ts"; + +export default class AuthWorker extends Cloudflare.Worker()( + "AuthWorker", + { + main: import.meta.url, + compatibility: { flags: ["nodejs_compat"] }, + assets: { directory: "./public", runWorkerFirst: ["/api/*"] }, + }, + Effect.gen(function* () { + const auth = yield* Auth; + return { fetch: auth.fetch }; + }).pipe(Effect.provide(AuthLive)), +) {} +``` + +Requests under `/api/*` reach Better Auth, including its callback and built-in error page. The static asset handler serves the HTML and browser bundle from `public`. + +## Define the stack + +Use this as `alchemy.run.ts`: + +```typescript +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import AuthWorker from "./src/worker.ts"; + +export default Alchemy.Stack( + "GoogleAuth", + { providers: Cloudflare.providers(), state: Alchemy.localState() }, + Effect.gen(function* () { + const worker = yield* AuthWorker; + return { url: worker.url }; + }), +); +``` + +This standalone stack exports the Worker's URL. Preserve the local state directory and use the same stage across restarts so resource identities and the signing secret remain stable. + +## Add the HTML controls + +Create `public/index.html`: + +```html + + + + + + Google sign-in + + +

Google sign-in

+ + + +

+ + + +``` + +These three controls will sign in, read the current session, and sign out. The module script runs after the document is parsed, so the client can find the controls immediately. + +## Initialize the browser client + +Create `src/client.ts`: + +```typescript +import { createAuthClient } from "better-auth/client"; + +const authClient = createAuthClient(); +const status = document.querySelector("#status")!; + +async function run(action: () => Promise) { + try { + status.textContent = await action(); + } catch { + status.textContent = "Could not reach the server. Try again."; + } +} +``` + +The client uses the current origin and `/api/auth`, with no server imports or credentials. `run` displays results as text and handles network failures for each action below. + +## Start Google sign-in + +Append to `src/client.ts`: + +```typescript +async function signIn(): Promise { + const { data, error } = await authClient.signIn.social({ + provider: "google", + callbackURL: "/", + disableRedirect: true, + }); + if (error) return "Could not start Google sign-in. Check your settings."; + if (!data?.url) return "No authorization URL was returned."; + window.location.assign(data.url); + return "Redirecting to Google…"; +} + +document.querySelector("#sign-in")! + .addEventListener("click", () => void run(signIn)); +``` + +The button requests an authorization URL and navigates to Google only if initiation succeeds. `callbackURL: "/"` is the successful return page, not the provider's registered callback endpoint. + +## Read the session + +Append to `src/client.ts`: + +```typescript +async function readSession(): Promise { + const { data, error } = await authClient.getSession(); + if (error) return "Could not read your session. Try again."; + return data ? `Signed in as ${data.user.name}` : "Not signed in."; +} + +document.querySelector("#session")! + .addEventListener("click", () => void run(readSession)); +void run(readSession); +``` + +The page checks the actual session on load, including after the OAuth redirect. The Read session button repeats that check; a successful redirect alone is not proof of authentication. + +## Sign out + +Append to `src/client.ts`: + +```typescript +async function signOut(): Promise { + const { error } = await authClient.signOut(); + return error ? "Could not sign out. Try again." : "Signed out."; +} + +document.querySelector("#sign-out")! + .addEventListener("click", () => void run(signOut)); +``` + +This asks Better Auth to end the session and reports the result. For account linking beyond this demo, use the upstream [account linking guide](https://www.better-auth.com/docs/concepts/users-accounts#account-linking) rather than bypassing verified-email checks or merging rows. + +## Bundle the browser module + +```sh +bun build ./src/client.ts --target browser --outdir ./public +``` + +This creates `public/client.js` for the HTML script tag. Rebuild it after editing the client. + +## Discover the local origin + +```sh +bunx alchemy dev --stage google-guide +``` + +Alchemy allocates an available port and prints the URL; open that exact URL, but leave sign-in alone until Google is configured. Keep `GOOGLE_ENABLED` and `AUTH_BASE_URL` unset on this first run so OAuth remains disabled and no origin is guessed. + +## Name the app on the consent screen + +In the [Google Cloud console](https://console.cloud.google.com/), select your project and open **Google Auth platform → Branding → Get started**. Enter the app name, support email, and developer contact information; supply the production homepage, privacy policy, and authorized domain before publishing. + +## Choose the audience + +In **Audience**, use **Internal** only for an eligible Workspace organization whose users should have access; otherwise choose **External**. For an External app in testing, add the accounts that will exercise the integration under **Audience → Test users**. + +## Limit the consent scopes + +In **Data Access**, configure only `openid`, `email`, and `profile` for ordinary sign-in; Better Auth requests these scopes by default. Google's [OAuth consent guide](https://developers.google.com/workspace/guides/configure-oauth-consent) covers the settings; do not add API scopes or force consent merely to create a session. + +## Register a web client + +```text +/api/auth/callback/google +``` + +Under **Google Auth platform → Clients → Create client**, choose **Web application** and append the path above to the printed local origin for the **Authorized redirect URI**. Authorized JavaScript origins are not a substitute for redirect URIs, and this flow does not require the Google browser SDK; see Google's [web-server OAuth guide](https://developers.google.com/identity/protocols/oauth2/web-server). + +## Save the server credentials + +Create `.env.local`: + +```dotenv +GOOGLE_ENABLED=true +GOOGLE_CLIENT_ID=replace-with-your-client-id +GOOGLE_CLIENT_SECRET=replace-with-your-client-secret +``` + +Copy the web client's ID and secret into these template values. Keep this file gitignored; never put the secret in `VITE_*`, browser code, source control, or logs. + +## Enable Google locally + +Stop the first dev process, then run: + +```sh +bunx alchemy dev --stage google-guide --env-file .env.local +``` + +If the printed origin changed, update Google's exact redirect registration before signing in. An optional `AUTH_BASE_URL` override must equal that origin without a trailing slash or `/api/auth`; leaving it unset avoids pinning an old dev port. + +## Try the sign-in flow + +Open the current Worker URL, select **Sign in with Google**, and choose an allowed account. Back on `/`, check the displayed session, then select **Sign out** and **Read session** to confirm it is gone. + +## Set a production origin + +Use this separate `.env.production` template: + +```dotenv +AUTH_BASE_URL=https://auth.example.com +GOOGLE_ENABLED=true +GOOGLE_CLIENT_ID=replace-with-production-client-id +GOOGLE_CLIENT_SECRET=replace-with-production-client-secret +``` + +Replace the origin with the actual HTTPS Worker origin and use separate credentials with redirect URI `https://auth.example.com/api/auth/callback/google`, adjusted to match. Keep the file gitignored and load these values for production; `AUTH_BASE_URL` does not attach a custom domain to the Worker. + +## Review access before publishing + +Before public launch, review publishing status and any required brand or scope verification; testing-mode access is not a production rollout, and Workspace administrators may impose additional restrictions. Google's stable subject identifies the provider account—an email suffix or hosted-domain login hint alone is not an application authorization policy. + +## Diagnose callback failures + +Better Auth displays callback failures on its built-in `/api/auth/error` page; no custom error query flag is needed. Keep state, PKCE, origin, and CSRF checks enabled. + +## Correct a redirect mismatch + +For `redirect_uri_mismatch`, compare the registered URI with the exact current scheme, hostname, port, and `/api/auth/callback/google` path, including any `AUTH_BASE_URL` override. Restart sign-in after correcting settings instead of replaying an authorization code. + +## Check access restrictions + +For `access_denied` or `org_internal`, check audience, test users, publishing status, and Workspace policy. Retry with an allowed account after correcting the configuration. + +## Check rejected credentials + +For `invalid_client`, verify that the credential pair belongs to the same web client and rotate revoked or expired secrets. Never log credentials, tokens, or complete callback URLs. diff --git a/.agents/skills/alchemy/references/better-auth/sign-in-providers/microsoft.md b/.agents/skills/alchemy/references/better-auth/sign-in-providers/microsoft.md new file mode 100644 index 00000000..c5edd23a --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/sign-in-providers/microsoft.md @@ -0,0 +1,344 @@ + + +# Microsoft + +> Add Microsoft sign-in to an Alchemy Worker with D1, one small step at a time. + +Start in an existing Alchemy project with [Bun](https://bun.sh) and a [configured Cloudflare profile](/cloudflare/setup). This standalone walkthrough uses Better Auth 1.7.5's built-in Microsoft Entra ID provider; all application files are introduced below. + +## Add the auth dependencies + +```sh +bun add @alchemy.run/better-auth better-auth@1.7.5 kysely +``` + +Use an `@alchemy.run/better-auth` release matching your installed Alchemy release. Keep your existing `alchemy` and `effect` dependencies. + +## Create the Auth service + +Create `src/auth.ts`: + +```typescript +import { BetterAuth } from "@alchemy.run/better-auth"; +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +const makeAuth = Effect.gen(function* () { + const baseURL = yield* Config.String("AUTH_BASE_URL").pipe(Config.option); + return yield* BetterAuth({ + basePath: "/api/auth", + baseURL: Option.getOrUndefined(baseURL), + account: { accountLinking: { disableImplicitLinking: true } }, + }); +}); + +export class Auth extends Context.Service< + Auth, + Effect.Success +>()("app/Auth") { + static readonly layer = Layer.effect(Auth, makeAuth); +} +``` + +The service keeps its options directly inside `BetterAuth` and reads Config during construction. Leaving `AUTH_BASE_URL` unset lets Better Auth use the incoming request's origin. + +## Start with Microsoft disabled + +Apply this addition to `src/auth.ts`: + +```diff lang="typescript" +const makeAuth = Effect.gen(function* () { + const baseURL = yield* Config.String("AUTH_BASE_URL").pipe(Config.option); ++ const enabled = yield* Config.Boolean("MICROSOFT_ENABLED").pipe( ++ Config.withDefault(false), ++ ); + return yield* BetterAuth({ +``` + +Microsoft stays disabled until you know the local URL and register its callback. The next addition reads credentials only when this switch is enabled. + +## Configure the Microsoft provider + +Apply both hunks to `src/auth.ts`, adding the import and the inline provider options: + +```diff lang="typescript" +import * as Option from "effect/Option"; ++import * as Redacted from "effect/Redacted"; +@@ + baseURL: Option.getOrUndefined(baseURL), ++ socialProviders: enabled ? { ++ microsoft: { ++ clientId: yield* Config.String("MICROSOFT_CLIENT_ID"), ++ clientSecret: Redacted.value(yield* Config.Redacted("MICROSOFT_CLIENT_SECRET")), ++ tenantId: yield* Config.String("MICROSOFT_TENANT_ID"), ++ disableProfilePhoto: true, ++ disableDefaultScope: true, ++ scope: ["openid", "profile", "email"], ++ }, ++ } : {}, + account: { accountLinking: { disableImplicitLinking: true } }, +``` + +This sign-in-only setup omits Graph photos and offline access. The provider defaults also request `User.Read` and `offline_access`; restore those features only with the corresponding delegated permissions and consent. + +## Declare the database + +Create `src/database.ts`: + +```typescript +import * as Cloudflare from "alchemy/Cloudflare"; + +export const AuthDb = Cloudflare.D1.Database("AuthDb"); +``` + +This declares the D1 database that will store users, accounts, and sessions. The same resource uses local D1 during `alchemy dev` and Cloudflare D1 when deployed. + +## Give Auth its database layer + +Create `src/auth-layer.ts`: + +```typescript +import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; +import * as Layer from "effect/Layer"; +import { Auth } from "./auth.ts"; +import { AuthDb } from "./database.ts"; + +export const AuthLive = Auth.layer.pipe( + Layer.provide(CloudflareD1(AuthDb)), +); +``` + +`CloudflareD1` supplies the database binding and automatic supported schema migrations. Alchemy also provisions the stable signing secret automatically; do not invent a manual secret for this example. + +## Host Auth in a Worker + +Create `src/worker.ts`: + +```typescript +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import { Auth } from "./auth.ts"; +import { AuthLive } from "./auth-layer.ts"; + +export default class AuthWorker extends Cloudflare.Worker()( + "AuthWorker", + { + main: import.meta.url, + compatibility: { flags: ["nodejs_compat"] }, + assets: { directory: "./public", runWorkerFirst: ["/api/*"] }, + }, + Effect.gen(function* () { + const auth = yield* Auth; + return { fetch: auth.fetch }; + }).pipe(Effect.provide(AuthLive)), +) {} +``` + +The Worker forwards API requests to Better Auth, including its session and callback endpoints. Static assets serve the browser page outside `/api/*`. + +## Define the stack + +Use this as `alchemy.run.ts` for the walkthrough: + +```typescript +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import AuthWorker from "./src/worker.ts"; + +export default Alchemy.Stack( + "MicrosoftAuth", + { providers: Cloudflare.providers(), state: Alchemy.localState() }, + Effect.gen(function* () { + const worker = yield* AuthWorker; + return { url: worker.url }; + }), +); +``` + +The output exposes the Worker's actual URL. Preserve the local state directory so subsequent runs retain resource identities and the signing secret. + +## Add the browser page + +Create `public/index.html`: + +```html + + + + + + Microsoft sign-in + + +

Microsoft sign-in

+ + + +

+ + + +``` + +The page provides three controls and a text-only status area. The browser bundle will be written to `public/client.js`. + +## Create the browser client + +Create `src/client.ts`: + +```typescript +import { createAuthClient } from "better-auth/client"; + +const authClient = createAuthClient(); +const status = document.getElementById("status")!; + +async function run(action: () => Promise) { + try { + status.textContent = await action(); + } catch { + status.textContent = "Could not reach the server. Try again."; + } +} +``` + +The client uses the page's origin and the default `/api/auth` path. It imports no server Auth module or credentials. + +## Start Microsoft sign-in + +Append to `src/client.ts`: + +```typescript +document.getElementById("sign-in")!.onclick = () => void run(async () => { + const { data, error } = await authClient.signIn.social({ + provider: "microsoft", + callbackURL: "/", + disableRedirect: true, + }); + if (error) return "Could not start Microsoft sign-in. Check the configuration."; + if (!data?.url) return "No authorization URL was returned."; + window.location.assign(data.url); + return "Redirecting to Microsoft…"; +}); +``` + +Better Auth creates the authorization request, and the browser follows the returned URL. A successful callback returns to `/`. + +## Read the current session + +Append to `src/client.ts`: + +```typescript +async function readSession(): Promise { + const { data, error } = await authClient.getSession(); + if (error) return "Could not check your session. Try again."; + return data ? `Signed in as ${data.user.name}.` : "Not signed in."; +} + +document.getElementById("read-session")!.onclick = () => void run(readSession); +void run(readSession); +``` + +The page checks the session on load, including after the OAuth callback. The button repeats that lookup without confusing a failed request with an anonymous session. + +## Sign out + +Append to `src/client.ts`: + +```typescript +document.getElementById("sign-out")!.onclick = () => void run(async () => { + const { error } = await authClient.signOut(); + return error ? "Could not sign out. Try again." : "Signed out."; +}); +``` + +This ends the application's session. It does not promise to sign the user out of every Microsoft application. + +## Bundle the browser client + +```sh +bun build ./src/client.ts --target browser --outdir ./public +``` + +The final client is the initial `src/client.ts` block followed by the three append blocks, in order. The final `src/auth.ts` is its initial module with both labeled diffs applied; every other module above is complete as shown. + +## Discover the local origin + +```sh +bunx alchemy dev --stage microsoft-guide +``` + +Run without provider credentials or `MICROSOFT_ENABLED=true` for this first start; Alchemy allocates a port and prints the actual origin. Keep Microsoft disabled until the registration below is ready, and rebuild the browser bundle whenever you edit `src/client.ts`. + +## Register the Entra application + +Open the [Microsoft Entra admin center](https://entra.microsoft.com/), select the intended directory, and choose **Entra ID → App registrations → New registration**. Record the **Application (client) ID** and **Directory (tenant) ID** from **Overview** after choosing your supported account types. + +For company-only sign-in, select the single-tenant option and use the directory's tenant GUID. Microsoft's [app registration guide](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) covers the registration screens. + +## Choose the tenant boundary + +Use the tenant GUID for a specific directory; for matching multitenant registrations, `organizations` admits work/school accounts, `consumers` admits personal accounts, and `common` admits both classes. This example requires an explicit tenant instead of silently defaulting to `common`. + +Better Auth 1.7.5 checks a specific tenant's issuer against `https://login.microsoftonline.com//v2.0`; multitenant endpoints bind the issuer to the token's `tid` and enforce the selected account class. A multitenant endpoint is not an organization allowlist: authorize customer tenants and application membership on the server, never from email domains or login hints. + +## Register the Web callback + +```text +/api/auth/callback/microsoft +``` + +Append this path to the printed origin and register the exact URL under **Authentication → Add a platform → Web**, not **Single-page application**. Better Auth exchanges the code server-side with a client secret, so leave implicit grants disabled and do not use wildcard callbacks. + +## Supply server credentials + +Create `.env.local.example`, then copy it to a gitignored `.env.local` and replace the placeholder values: + +```dotenv +MICROSOFT_ENABLED=true +MICROSOFT_CLIENT_ID=replace-with-application-client-id +MICROSOFT_CLIENT_SECRET=replace-with-secret-value +MICROSOFT_TENANT_ID=replace-with-directory-tenant-guid +# Leave AUTH_BASE_URL unset for local request-origin discovery. +``` + +Under **Certificates & secrets → Client secrets → New client secret**, copy the secret **Value**, not its **Secret ID**, and arrange rotation before expiry. These server-side Config values must stay out of `VITE_*`, browser bundles, source control, and logs. + +## Enable Microsoft locally + +```sh +bunx alchemy dev --stage microsoft-guide --env-file .env.local +``` + +Stop the first dev process before running this command, and update the registered callback if the newly printed origin changes. Open that origin, select **Sign in with Microsoft**, then use **Read session** and **Sign out** to exercise the roundtrip. + +## Set the production origin + +Create `.env.production.example` as a template for your private production configuration: + +```dotenv +AUTH_BASE_URL=https://auth.example.com +MICROSOFT_ENABLED=true +MICROSOFT_CLIENT_ID=replace-with-production-application-client-id +MICROSOFT_CLIENT_SECRET=replace-with-production-secret-value +MICROSOFT_TENANT_ID=replace-with-production-directory-tenant-guid +``` + +Replace the example origin with the actual HTTPS Worker origin, without a trailing slash or `/api/auth`; `baseURL` does not attach a domain to the Worker. Use a separate production registration and secret, register that origin plus `/api/auth/callback/microsoft`, and supply these values through your deployment's server configuration. + +## Preserve provider identity + +The built-in provider derives the account subject from Microsoft's immutable `oid`, not email or `preferred_username`; do not assume one person has the same identity across tenants. Microsoft may omit email or a trustworthy verification signal, so never fabricate an address or force `emailVerified: true` to bypass a failure. + +Keep implicit linking disabled and retain provider-email and same-email checks if you later add account linking. Follow Better Auth's [account-linking guidance](https://www.better-auth.com/docs/concepts/users-accounts#account-linking) rather than trusting Microsoft solely to merge accounts by email. + +## Diagnose callback failures + +Better Auth displays callback failures on its built-in `/api/auth/error` page; no custom application error query is needed. Keep state, PKCE, origin, and CSRF protections enabled. + +For `AADSTS50011`, compare the exact Web callback with the actual origin and `/api/auth/callback/microsoft`; for client failures, check the client ID, secret value, and expiry. For tenant, consent, or missing-email failures, review supported account types, user assignment, administrator consent, and claim configuration without logging tokens or full callback URLs. diff --git a/.agents/skills/alchemy/references/better-auth/tutorial/part-1.md b/.agents/skills/alchemy/references/better-auth/tutorial/part-1.md new file mode 100644 index 00000000..c9d8c064 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/tutorial/part-1.md @@ -0,0 +1,153 @@ + + +# Part 1: Create an Auth service + +> Install Better Auth, provide a D1 database layer, and create an Effect service. + +Build one application with email/password authentication, an Effect HTTP API, and GitHub sign-in. This tutorial uses Bun and Cloudflare D1; [other databases](/better-auth/databases) use the same Auth service. + +Use your existing Alchemy project with [Bun](https://bun.sh) and a [configured Cloudflare profile](/cloudflare/setup). The examples use Effect 4 and Better Auth 1.7.5. + +## Install the module + +```sh +bun add @alchemy.run/better-auth better-auth@1.7.5 kysely +``` + +Use matching Alchemy and Better Auth integration releases. D1 uses Kysely internally; application code does not need to construct a Kysely client. + +## Declare the database + +```typescript +// src/database.ts +import * as Cloudflare from "alchemy/Cloudflare"; + +export const AuthDb = Cloudflare.D1.Database("AuthDb"); +``` + +Alchemy creates the database when you deploy. During local development, D1 runs in the local simulator. + +## Choose the authentication method + +```typescript +// src/auth.ts +import { BetterAuth } from "@alchemy.run/better-auth"; + +const makeAuth = BetterAuth({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, +}); +``` + +Alchemy provisions a stable signing secret and applies D1 schema migrations. You do not need to create auth tables or supply a signing secret. + +## Expose an Effect service + +```diff lang="typescript" + // src/auth.ts + import { BetterAuth } from "@alchemy.run/better-auth"; ++import * as Context from "effect/Context"; ++import * as Effect from "effect/Effect"; ++import * as Layer from "effect/Layer"; + + const makeAuth = BetterAuth({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, + }); ++ ++export class Auth extends Context.Service< ++ Auth, ++ Effect.Success ++>()("app/Auth") { ++ static readonly layer = Layer.effect(Auth, makeAuth); ++} +``` + +Handlers and middleware can now request the same typed service with `yield* Auth`. + +## Create the Worker + +```typescript +// src/worker.ts +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; + +export default class AuthApi extends Cloudflare.Worker()( + "AuthApi", + { + main: import.meta.url, + compatibility: { flags: ["nodejs_compat"] }, + }, + Effect.gen(function* () { + return { fetch: Effect.succeed(HttpServerResponse.text("Ready")) }; + }), +) {} +``` + +Alchemy selects an available local port. Use the URL it prints rather than assuming a particular port. + +## Provide the database layer + +```diff lang="typescript" + // src/worker.ts ++import { CloudflareD1 } from "@alchemy.run/better-auth/CloudflareD1"; ++import * as Layer from "effect/Layer"; ++import { Auth } from "./auth.ts"; ++import { AuthDb } from "./database.ts"; + + Effect.gen(function* () { ++ yield* Auth; + return { fetch: Effect.succeed(HttpServerResponse.text("Ready")) }; +- }), ++ }).pipe( ++ Effect.provide(Auth.layer.pipe(Layer.provide(CloudflareD1(AuthDb)))), ++ ), +``` + +The host supplies the database; the Auth service stays independent of Cloudflare. This also registers the database binding and migration action. + +## Create the stack + +```typescript +// alchemy.run.ts +import * as Alchemy from "alchemy"; +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; +import AuthApi from "./src/worker.ts"; + +export default Alchemy.Stack( + "BetterAuthTutorial", + { providers: Cloudflare.providers(), state: Alchemy.localState() }, + Effect.gen(function* () { + const api = yield* AuthApi; + return { url: api.url }; + }), +); +``` + +Local state keeps this walkthrough self-contained. Preserve its state directory so future deployments retain the signing secret and resource identities. + +## Start development + +```sh +bunx alchemy dev --stage tutorial +``` + +In another terminal, paste the printed URL after running `read`, then press Enter: + +```sh +read -r DEV_URL +export DEV_URL +curl "$DEV_URL" +``` + +Keep this shell for the following API checks. Update `DEV_URL` if a later restart prints a different address. + +```text +Ready +``` + +Continue to [Part 2: Mount the HTTP API](/better-auth/tutorial/part-2). diff --git a/.agents/skills/alchemy/references/better-auth/tutorial/part-2.md b/.agents/skills/alchemy/references/better-auth/tutorial/part-2.md new file mode 100644 index 00000000..584c44d2 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/tutorial/part-2.md @@ -0,0 +1,116 @@ + + +# Part 2: Mount the HTTP API + +> Serve Better Auth routes and an Effect HTTP API from the same Worker. + +Continue from [Part 1](/better-auth/tutorial/part-1). Keep Better Auth's routes public while building your application's API alongside them. + +## Describe a health endpoint + +```typescript +// src/api.ts +import * as Schema from "effect/Schema"; +import * as HttpApi from "effect/http-api/HttpApi"; +import * as HttpApiEndpoint from "effect/http-api/HttpApiEndpoint"; +import * as HttpApiGroup from "effect/http-api/HttpApiGroup"; + +export class PublicApi extends HttpApiGroup.make("public").add( + HttpApiEndpoint.get("health", "/api/health", { + success: Schema.Struct({ ok: Schema.Boolean }), + }), +) {} + +export class AppApi extends HttpApi.make("app").add(PublicApi) {} +``` + +The schema declares the response shape. No authentication is required for this endpoint. + +## Implement the endpoint + +```typescript +// src/http.ts +import * as Http from "alchemy/Http"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; +import { AppApi } from "./api.ts"; + +const PublicLive = HttpApiBuilder.group(AppApi, "public", (handlers) => + handlers.handle("health", () => Effect.succeed({ ok: true })), +); + +export const HttpLive = HttpApiBuilder.layer(AppApi).pipe( + Layer.provide(PublicLive), + Layer.provide(Http.Platform), +); +``` + +The handler supplies the declared response. Alchemy's HTTP platform layer provides the services needed to build the API on a Worker. + +## Build the router + +```diff lang="typescript" + // src/worker.ts ++import * as HttpRouter from "effect/http/HttpRouter"; ++import { HttpLive } from "./http.ts"; + + Effect.gen(function* () { + yield* Auth; +- return { fetch: Effect.succeed(HttpServerResponse.text("Ready")) }; ++ const fetch = yield* HttpRouter.toHttpEffect(HttpLive); ++ return { fetch }; +``` + +Build the router once during Worker construction. Remove the now-unused `HttpServerResponse` import. + +## Mount Better Auth + +```diff lang="typescript" + // src/worker.ts ++import { HttpServerRequest } from "effect/http/HttpServerRequest"; + + Effect.gen(function* () { +- yield* Auth; ++ const auth = yield* Auth; + const fetch = yield* HttpRouter.toHttpEffect(HttpLive); +- return { fetch }; ++ return { ++ fetch: Effect.gen(function* () { ++ const request = yield* HttpServerRequest; ++ const pathname = request.url.split("?")[0]; ++ if (pathname === "/api/auth" || pathname.startsWith("/api/auth/")) { ++ return yield* auth.fetch; ++ } ++ return yield* fetch; ++ }), ++ }; +``` + +Better Auth handles its own endpoints and response cookies. Other paths go through the Effect HTTP API. + +## Check the public API + +```sh +curl "$DEV_URL/api/health" +``` + +```json +{"ok":true} +``` + +## Check an anonymous session + +```sh +curl "$DEV_URL/api/auth/get-session" +``` + +```json +null +``` + +An anonymous request has no session. A database or authentication failure is an error, not an anonymous session. + +Continue to [Part 3: Build sign-in](/better-auth/tutorial/part-3). diff --git a/.agents/skills/alchemy/references/better-auth/tutorial/part-3.md b/.agents/skills/alchemy/references/better-auth/tutorial/part-3.md new file mode 100644 index 00000000..493a4d20 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/tutorial/part-3.md @@ -0,0 +1,168 @@ + + +# Part 3: Build sign-in + +> Add a same-origin browser client with sign-up, sign-in, session lookup, and sign-out. + +Continue from [Part 2](/better-auth/tutorial/part-2). Serve a small browser client from the same Worker so cookies work without cross-origin configuration. + +## Create the client + +```typescript +// src/client.ts +import { createAuthClient } from "better-auth/client"; + +export const authClient = createAuthClient(); +``` + +The client uses the current origin and the default `/api/auth` path. It contains no database credentials or signing secret. + +## Add a form + +```html + + + + + + + Auth tutorial + + +

Auth tutorial

+
+ + + + + +
+ + +

+ + + +``` + +Create the `public` directory before saving this file. Keep this page public so signed-out users can reach it. + +## Read the form + +```typescript +// src/ui.ts +import { authClient } from "./client.ts"; + +const form = document.querySelector("#auth-form")!; +const status = document.querySelector("#status")!; +const show = (message: string) => { status.textContent = message; }; + +const credentials = () => { + const fields = new FormData(form); + return { + name: String(fields.get("name") ?? ""), + email: String(fields.get("email") ?? ""), + password: String(fields.get("password") ?? ""), + }; +}; +``` + +Use text content for status messages rather than inserting server responses as HTML. + +## Create an account + +```typescript +// Append to src/ui.ts +const signUp = async () => { + if (!form.reportValidity()) return; + const input = credentials(); + if (!input.name.trim()) return show("Enter a name to create an account."); + const { data, error } = await authClient.signUp.email(input); + show(error ? error.message ?? "Sign-up failed" : `Created ${data.user.email}`); +}; + +document.querySelector("#sign-up")!.addEventListener("click", () => { + void signUp().catch(() => show("Unable to reach the server.")); +}); +``` + +The server creates the user and sets the session cookie. Later, the [email/password guide](/better-auth/sign-in-providers/email-password) adds email verification and password reset. + +## Sign in + +```typescript +// Append to src/ui.ts +const signIn = async () => { + const { email, password } = credentials(); + const { data, error } = await authClient.signIn.email({ email, password }); + show(error ? error.message ?? "Sign-in failed" : `Signed in as ${data.user.email}`); +}; + +form.addEventListener("submit", (event) => { + event.preventDefault(); + void signIn().catch(() => show("Unable to reach the server.")); +}); +``` + +A failed sign-in stays on the form and displays the error. + +## Read the session + +```typescript +// Append to src/ui.ts +const readSession = async () => { + const { data, error } = await authClient.getSession(); + show(error ? error.message ?? "Session lookup failed" + : data ? `Signed in as ${data.user.email}` : "Signed out"); +}; + +document.querySelector("#session")!.addEventListener("click", () => { + void readSession().catch(() => show("Unable to reach the server.")); +}); +``` + +The browser sends its session cookie automatically. The server remains authoritative about whether the user is signed in. + +## Sign out + +```typescript +// Append to src/ui.ts +const signOut = async () => { + const { error } = await authClient.signOut(); + show(error ? error.message ?? "Sign-out failed" : "Signed out"); +}; + +document.querySelector("#sign-out")!.addEventListener("click", () => { + void signOut().catch(() => show("Unable to reach the server.")); +}); +``` + +Sign-out revokes the session and clears its cookie. + +## Build the browser code + +```sh +bun build ./src/ui.ts --target browser --outdir ./public +``` + +Run this again whenever the browser source changes. Only browser code belongs in this bundle; never import the server's Auth service here. + +## Serve the assets + +```diff lang="typescript" + // src/worker.ts + { + main: import.meta.url, ++ assets: "./public", + compatibility: { flags: ["nodejs_compat"] }, +``` + +Cloudflare serves matching static files and forwards API requests to the Worker. The form and authentication endpoints share an origin. + +## Try the flow + +Open the URL printed by Alchemy, create an account, and read the session. Sign out, try an incorrect password, then sign in with the correct password. + +Continue to [Part 4: Protect API endpoints](/better-auth/tutorial/part-4). diff --git a/.agents/skills/alchemy/references/better-auth/tutorial/part-4.md b/.agents/skills/alchemy/references/better-auth/tutorial/part-4.md new file mode 100644 index 00000000..4c19061c --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/tutorial/part-4.md @@ -0,0 +1,213 @@ + + +# Part 4: Protect API endpoints + +> Resolve the session in Effect HTTP API middleware and provide a typed CurrentUser service. + +Continue from [Part 3](/better-auth/tutorial/part-3). Protect a new `/api/me` endpoint without blocking sign-in or the public health check. + +## Describe the current user + +```typescript +// src/current-user.ts +import * as Context from "effect/Context"; +import * as Schema from "effect/Schema"; + +export const User = Schema.Struct({ + id: Schema.String, + name: Schema.String, + email: Schema.String, +}); + +export class CurrentUser extends Context.Service< + CurrentUser, + typeof User.Type +>()("app/CurrentUser") {} +``` + +Handlers receive only the user fields they need. Session tokens stay out of API responses. + +## Declare an unauthorized response + +```typescript +// src/middleware.ts +import * as Schema from "effect/Schema"; + +export class Unauthorized extends Schema.TaggedError()( + "Unauthorized", + {}, + { httpApiStatus: 401 }, +) {} +``` + +A request without a session returns a typed `401`. + +## Distinguish authentication failures + +```typescript +// Append to src/middleware.ts +export class AuthenticationUnavailable extends Schema.TaggedError()( + "AuthenticationUnavailable", + {}, + { httpApiStatus: 503 }, +) {} +``` + +A failed session lookup returns `503`, not `401`. Its response exposes no database errors or credentials. + +## Declare the middleware + +```diff lang="typescript" + // src/middleware.ts ++import * as HttpApiMiddleware from "effect/http-api/HttpApiMiddleware"; ++import { CurrentUser } from "./current-user.ts"; ++ ++export class Authentication extends HttpApiMiddleware.Service< ++ Authentication, ++ { provides: CurrentUser } ++>()("app/Authentication", { ++ error: [Unauthorized, AuthenticationUnavailable], ++}) {} +``` + +The middleware declares the service it provides and the errors callers can receive. + +## Implement session authentication + +```diff lang="typescript" + // src/middleware.ts ++import { RuntimeContext } from "alchemy"; ++import * as Effect from "effect/Effect"; ++import * as Layer from "effect/Layer"; ++import { Auth } from "./auth.ts"; + + error: [Unauthorized, AuthenticationUnavailable], +-}) {} ++}) { ++ static readonly layer = Layer.effect( ++ Authentication, ++ Effect.gen(function* () { ++ const auth = yield* Auth; ++ return (httpEffect) => Effect.gen(function* () { ++ const session = yield* auth.getSession().pipe( ++ Effect.mapError(() => new AuthenticationUnavailable()), ++ Effect.catchDefect(() => Effect.fail(new AuthenticationUnavailable())), ++ ); ++ if (session === null) return yield* Effect.fail(new Unauthorized()); ++ return yield* Effect.provideService(httpEffect, CurrentUser, { ++ id: session.user.id, ++ name: session.user.name, ++ email: session.user.email, ++ }); ++ }).pipe(Effect.provide(RuntimeContext.phantom)); ++ }), ++ ); ++} +``` + +The Auth service is captured once; the session is read inside each request. `RuntimeContext.phantom` adapts Alchemy's runtime-only methods to the native HTTP middleware boundary; it does not create a session or bypass authentication. + +## Protect one API group + +```diff lang="typescript" + // src/api.ts ++import { User } from "./current-user.ts"; ++import { Authentication } from "./middleware.ts"; ++ ++export class PrivateApi extends HttpApiGroup.make("private") ++ .add(HttpApiEndpoint.get("me", "/api/me", { success: User })) ++ .middleware(Authentication) {} + +-export class AppApi extends HttpApi.make("app").add(PublicApi) {} ++export class AppApi extends HttpApi.make("app") ++ .add(PublicApi) ++ .add(PrivateApi) {} +``` + +Only the private group requires authentication. Better Auth's own routes and the public group remain accessible while signed out. + +## Read the user in a handler + +```diff lang="typescript" + // src/http.ts ++import { CurrentUser } from "./current-user.ts"; ++ ++const PrivateLive = HttpApiBuilder.group(AppApi, "private", (handlers) => ++ handlers.handle("me", () => CurrentUser), ++); + + export const HttpLive = HttpApiBuilder.layer(AppApi).pipe( +- Layer.provide(PublicLive), ++ Layer.provide(Layer.mergeAll(PublicLive, PrivateLive)), + Layer.provide(Http.Platform), + ); +``` + +The middleware supplies `CurrentUser` before the handler runs. The handler does not parse cookies itself. + +## Provide the middleware implementation + +```diff lang="typescript" + // src/http.ts ++import { Authentication } from "./middleware.ts"; + + export const HttpLive = HttpApiBuilder.layer(AppApi).pipe( + Layer.provide(Layer.mergeAll(PublicLive, PrivateLive)), ++ Layer.provide(Authentication.layer), + Layer.provide(Http.Platform), + ); +``` + +The Worker's existing `Auth.layer` supplies the middleware's Auth dependency. No additional database connection configuration is needed. + +## Call the protected endpoint + +Add a button to the existing page: + +```diff lang="html" + + ++ +``` + +Wire it to the API: + +```typescript +// Append to src/ui.ts +const readMe = async () => { + const response = await fetch("/api/me"); + if (response.status === 401) return show("Sign in first."); + if (!response.ok) return show("Unable to read your account."); + const user = await response.json(); + show(`API user: ${user.email}`); +}; + +document.querySelector("#me")!.addEventListener("click", () => { + void readMe().catch(() => show("Unable to reach the server.")); +}); +``` + +Rebuild the browser bundle: + +```sh +bun build ./src/ui.ts --target browser --outdir ./public +``` + +## Verify the boundary + +```sh +curl -i "$DEV_URL/api/me" +``` + +```http +HTTP/1.1 401 Unauthorized +Content-Type: application/json + +{"_tag":"Unauthorized"} +``` + +In the browser, sign in and select **Call protected API**; it should show your email. Sign out and repeat; it should ask you to sign in while `/api/health` still returns `200`. + +Continue to [Part 5: Add GitHub sign-in](/better-auth/tutorial/part-5). diff --git a/.agents/skills/alchemy/references/better-auth/tutorial/part-5.md b/.agents/skills/alchemy/references/better-auth/tutorial/part-5.md new file mode 100644 index 00000000..8a66ca66 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/tutorial/part-5.md @@ -0,0 +1,146 @@ + + +# Part 5: Add GitHub sign-in + +> Register a GitHub OAuth app, bind its credentials through Effect Config, and sign in from the browser. + +Continue from [Part 4](/better-auth/tutorial/part-4). GitHub credentials come from a GitHub OAuth application; Alchemy's automatic signing secret is a separate value. + +## Register the local OAuth application + +Use the `DEV_URL` captured from Alchemy in [Part 1](/better-auth/tutorial/part-1). Print the two URLs to register: + +```sh +printf 'Homepage URL: %s\n' "$DEV_URL" +printf 'Authorization callback URL: %s/api/auth/callback/github\n' "$DEV_URL" +``` + +Open [GitHub's new OAuth application form](https://github.com/settings/applications/new), name the app, and paste those values into its homepage and callback fields. + +Use a separate OAuth application for production so its credentials remain independent of local development. + +## Keep credentials out of Git + +```gitignore +# .gitignore +.env +.env.* +!.env.example +node_modules/ +.alchemy/ +public/ui.js +``` + +Preserve the local `.alchemy` directory even though it is ignored by Git; it contains this tutorial's deployment state. + +## Supply the GitHub credentials + +Generate a client secret in the GitHub application settings, then save the values locally: + +```dotenv +# .env +GITHUB_CLIENT_ID=your-local-oauth-client-id +GITHUB_CLIENT_SECRET=your-local-oauth-client-secret +``` + +These names are application configuration keys, not built-in Better Auth environment variables. The next step reads them explicitly. + +## Read the client ID + +```diff lang="typescript" + // src/auth.ts ++import * as Config from "effect/Config"; + +-const makeAuth = BetterAuth({ +- basePath: "/api/auth", +- emailAndPassword: { enabled: true }, ++const makeAuth = Effect.gen(function* () { ++ const clientId = yield* Config.String("GITHUB_CLIENT_ID"); ++ return yield* BetterAuth({ ++ basePath: "/api/auth", ++ emailAndPassword: { enabled: true }, ++ }); + }); +``` + +Resolve configuration while constructing the Auth layer so Alchemy discovers and binds it. Reading it only inside a request handler would be too late for deployment-time binding discovery. + +## Read the client secret + +```diff lang="typescript" + // src/auth.ts ++import * as Redacted from "effect/Redacted"; + + const makeAuth = Effect.gen(function* () { + const clientId = yield* Config.String("GITHUB_CLIENT_ID"); ++ const clientSecret = yield* Config.Redacted("GITHUB_CLIENT_SECRET"); + return yield* BetterAuth({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, + }); + }); +``` + +Keep the secret redacted until the library needs the string. Neither credential belongs in the browser bundle. + +## Enable GitHub + +```diff lang="typescript" + // src/auth.ts + return yield* BetterAuth({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, ++ socialProviders: { ++ github: { ++ clientId, ++ clientSecret: Redacted.value(clientSecret), ++ }, ++ }, + }); +``` + +The existing Auth service and middleware infer the updated options. The database layer stays unchanged. + +## Add a sign-in button + +```diff lang="html" + + ++ +``` + +## Start the authorization flow + +```typescript +// Append to src/ui.ts +const signInWithGitHub = async () => { + const { error } = await authClient.signIn.social({ + provider: "github", + callbackURL: "/", + }); + if (error) show(error.message ?? "GitHub sign-in failed"); +}; + +document.querySelector("#github")!.addEventListener("click", () => { + void signInWithGitHub().catch(() => show("Unable to reach the server.")); +}); +``` + +The client redirects to GitHub and returns through the registered callback. The resulting session works with the same `/api/me` endpoint. + +Better Auth handles failed callbacks on its built-in error page. No custom error query parameter or callback handler is needed. + +## Restart with the new configuration + +Stop the running development command, rebuild the client, and start it again: + +```sh +bun build ./src/ui.ts --target browser --outdir ./public +bunx alchemy dev --stage tutorial +``` + +Try GitHub sign-in and call the protected API. Then sign out, retry, and cancel at GitHub to check the error path. + +Use the [GitHub provider guide](/better-auth/sign-in-providers/github) for account linking and provider-specific settings. Continue to [Part 6: Deploy and verify](/better-auth/tutorial/part-6). diff --git a/.agents/skills/alchemy/references/better-auth/tutorial/part-6.md b/.agents/skills/alchemy/references/better-auth/tutorial/part-6.md new file mode 100644 index 00000000..9e38cb83 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/tutorial/part-6.md @@ -0,0 +1,121 @@ + + +# Part 6: Deploy and verify + +> Deploy the authenticated API, configure production OAuth callbacks, and verify session boundaries. + +Continue from [Part 5](/better-auth/tutorial/part-5). Use a separate deployment stage and GitHub OAuth application for production. + +## Choose a production domain + +If your domain is managed by Cloudflare, attach it to the Worker: + +```diff lang="typescript" + // src/worker.ts + { + main: import.meta.url, ++ domain: "auth.example.com", + assets: "./public", +``` + +Replace the example with a hostname you own. Alchemy configures the Worker domain; see [custom domains](/cloudflare/networking/custom-domains) for DNS prerequisites and workers.dev alternatives. + +## Register the production OAuth app + +Create another [GitHub OAuth application](https://github.com/settings/applications/new): + +```text +Application name: Auth tutorial — production +Homepage URL: https://auth.example.com +Authorization callback URL: https://auth.example.com/api/auth/callback/github +``` + +Leave the local application's callback unchanged. + +## Configure the production origin + +```diff lang="typescript" + // src/auth.ts ++import * as Option from "effect/Option"; ++ + const makeAuth = Effect.gen(function* () { ++ const baseURL = yield* Config.String("AUTH_BASE_URL").pipe(Config.option); + const clientId = yield* Config.String("GITHUB_CLIENT_ID"); + const clientSecret = yield* Config.Redacted("GITHUB_CLIENT_SECRET"); + return yield* BetterAuth({ + basePath: "/api/auth", + emailAndPassword: { enabled: true }, ++ baseURL: Option.getOrUndefined(baseURL), + socialProviders: { +``` + +An explicit origin makes production callback construction predictable. This is application URL configuration, not a replacement for Alchemy's generated signing secret. + +## Keep the local origin automatic + +Leave `AUTH_BASE_URL` unset locally; Better Auth derives it from the incoming request. Keep the local GitHub application's callback aligned with the URL Alchemy prints, updating it if a restart changes the address. + +## Supply production credentials + +```dotenv +# .env.production +AUTH_BASE_URL=https://auth.example.com +GITHUB_CLIENT_ID=your-production-oauth-client-id +GITHUB_CLIENT_SECRET=your-production-oauth-client-secret +``` + +Keep this file out of Git. In CI, supply the same configuration names through the deployment environment's secret store. + +## Add build-and-deploy commands + +Add these scripts to your existing package manifest: + +```json +{ + "scripts": { + "dev": "bun build ./src/ui.ts --target browser --outdir ./public && alchemy dev", + "deploy": "bun build ./src/ui.ts --target browser --outdir ./public && alchemy deploy" + } +} +``` + +Both commands rebuild the browser bundle before starting Alchemy. + +## Deploy a separate stage + +```sh +bun --env-file=.env.production run deploy --stage production +``` + +Alchemy provisions a separate database and signing secret for this stage, applies schema changes, and deploys the Worker. Subsequent deploys retain the same secret and database identities through stack state. + +## Check anonymous access + +```sh +curl -i https://auth.example.com/api/health +curl -i https://auth.example.com/api/me +``` + +The health check should return `200`; the protected endpoint should return `401`. + +## Check browser authentication + +Open your production site, create an account, and select **Call protected API**. Sign out and verify the same button asks you to sign in. + +Sign in with GitHub and repeat the protected request. Cancel a second GitHub sign-in attempt to verify the error message appears after the callback. + +## Check persistence + +```sh +bun --env-file=.env.production run deploy --stage production +``` + +Refresh a previously signed-in browser after redeployment. Its existing session should still work; do not delete stack state or rotate the signing secret to deploy an update. + +## Keep future changes safe + +Review [migrations](/better-auth/guides/migrations) before changing the schema, and preserve the [signing identity](/better-auth/guides/configuration#preserve-the-signing-identity) across deployments. For authentication features beyond the Alchemy integration, see the [Better Auth documentation](https://better-auth.com/docs). + +The [complete companion application](https://github.com/alchemy-run/alchemy/tree/main/examples/cloudflare-better-auth) contains the assembled files. Its GitHub integration is opt-in so the email/password flow runs before external credentials are available. diff --git a/.agents/skills/alchemy/references/better-auth/upgrades/from-1-6-to-1-7.md b/.agents/skills/alchemy/references/better-auth/upgrades/from-1-6-to-1-7.md new file mode 100644 index 00000000..8ff8cab6 --- /dev/null +++ b/.agents/skills/alchemy/references/better-auth/upgrades/from-1-6-to-1-7.md @@ -0,0 +1,154 @@ + + +# Upgrading from 1.6 to 1.7.5 or newer + +> Upgrade Better Auth directly from 1.6, preserve existing users, and update your schema and application APIs. + +Upgrade directly to 1.7.5 or a later compatible 1.x release. The core account +schema is unchanged from 1.6; no issuer column or identity backfill is needed. + +:::caution[Already upgraded to 1.7.0–1.7.2?] +Follow the [upstream issuer cleanup instructions](https://better-auth.com/docs/guides/1-7-upgrade-guide#account-identity-keeps-the-provider-key) first. +::: + +## Back up existing data + +Back up the database and rehearse on a copy with existing users, accounts, and sessions. **Keep the same auth secret.** + +Resolve duplicate provider/account ID pairs using your configured table names. Never merge users by email alone. + +These integrations need manual preparation. Follow the corresponding +[upstream instructions](https://better-auth.com/docs/guides/1-7-upgrade-guide): + +| Integration | Before deploying | +| --- | --- | +| Microsoft / Entra | Pause sign-in and linking; map old subject IDs to verified object IDs or a trusted directory export. | +| Custom OAuth / SSO | Map existing subjects to verified immutable provider IDs. | +| Legacy OIDC / MCP | Convert client registrations and legacy token tables. Schema migrations do not copy these records. | +| SCIM | Stop old provisioning and follow the reprovisioning procedure. D1 cannot support the new transaction requirements. | +| Device Authorization | Resolve duplicate device/user codes; use bounded strings on MySQL and SQL Server. | + +:::caution[Deployment does not pause existing traffic] +Alchemy delays the new host deployment, but old instances keep running; use a maintenance window for incompatible changes. An application rollback does not reverse database changes. +::: + +## Upgrade packages together + +Update the Alchemy Better Auth package to a release supporting 1.7.5, then +align Better Auth and its synchronized plugins/adapters: + +```sh +npx auth@1.7.5 upgrade +``` + +Use Node.js 22.12 or newer and the CLI version matching your target release. Review workspace catalogs and overrides. + +The command updates dependencies, not application code or identity data. + +## Apply the schema + +For [SQL layers with automatic migrations](/better-auth/guides/migrations#choose-the-migration-transport), +the wrapper stays unchanged: + +```typescript +import { BetterAuth } from "@alchemy.run/better-auth"; + +export const makeAuth = BetterAuth({ + emailAndPassword: { enabled: true }, +}); +``` + +After any manual preparation, deploy: + +```sh +alchemy deploy +``` + +The schema is applied before the new host. Ordinary email/password accounts +need no identity conversion when upgrading directly from 1.6. + +## Generate a Drizzle schema + +Drizzle users own schema migrations. Generate and review the auth schema: + +```sh +npx auth@1.7.5 generate --config ./auth.cli.ts +``` + +The CLI configuration exports an upstream Better Auth instance with the same +plugins and schema options, using the Relations v2 adapter: + +```typescript +import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2"; +``` + +Omit the adapter CLI flag, which selects the legacy generator: + +```diff +- npx auth@1.7.5 generate --config ./auth.cli.ts --adapter drizzle ++ npx auth@1.7.5 generate --config ./auth.cli.ts +``` + +Apply the schema through Drizzle's migration tooling and include the generated auth relations when constructing the database. See the complete [Drizzle configuration](/better-auth/databases/drizzle). + +With automatic migrations disabled, apply your schema before deploying too. + +## Update application APIs + +Generic OAuth now uses the social APIs: + +```typescript +import { createAuthClient } from "better-auth/client"; +import { genericOAuthClient } from "better-auth/client/plugins"; + +const authClient = createAuthClient({ plugins: [genericOAuthClient()] }); +authClient.signIn.social({ provider: "my-provider" }); +authClient.linkSocial({ provider: "my-provider" }); +``` + +Update registered callback URLs: + +```diff +- /api/auth/oauth2/callback/:id ++ /api/auth/callback/:id +``` + +Token/profile operations select a **local account row**, rather than a provider: + +```typescript +{ accountId: "local-account-row-id" } +// Or select the account from its cookie: +{ useAccountCookie: true } +``` + +Move joins into the database options: + +```diff +- experimental: { joins: true } ++ advanced: { database: { joins: true } } +``` + +Custom secondary storage needs atomic operations: + +```typescript +increment(key, ttl) +getAndDelete(key) +``` + +The TTL is required, in seconds; subsequent increments must preserve the original expiration. Custom rate-limit storage now uses an atomic consume operation instead of separate reads and writes. + +Check the upstream guide for SSO, MCP, mobile, and proxy changes. + +## Verify existing users + +Test an existing session, existing-user sign-in, new-user sign-up, and sign-out. Exercise every configured provider and account link/list/refresh/unlink flow. + +Confirm user IDs are preserved and no duplicate users appear. + +Deploy again to confirm the migration is a no-op: + +```sh +alchemy deploy +``` diff --git a/.agents/skills/alchemy/references/cli/INDEX.md b/.agents/skills/alchemy/references/cli/INDEX.md new file mode 100644 index 00000000..324ef758 --- /dev/null +++ b/.agents/skills/alchemy/references/cli/INDEX.md @@ -0,0 +1,20 @@ +# cli index + +14 pages. Every alchemy command at a glance — the command map, common options, and how the interactive TUI decides when to render. + +| Page | File | Covers | +| --- | --- | --- | +| CLI | `_overview.md` | Every alchemy command at a glance — the command map, common options, and how the interactive TUI decides when to render. | +| Adopting Resources | `adopting-resources.md` | How alchemy takes ownership of pre-existing cloud resources — recovery by default, --adopt for foreign resources, and the programmatic AdoptPolicy. | +| aws | `aws.md` | AWS provider commands — bootstrap the per-account assets bucket that Lambda deployments rely on. | +| cloudflare | `cloudflare.md` | Cloudflare provider commands — bootstrap the state-store worker, mint API tokens, and inspect state-store logs. | +| deploy | `deploy.md` | Compute a plan, ask for approval, and create/update/delete resources to match the desired state. | +| destroy | `destroy.md` | Delete every resource in a stack — plan all existing resources for deletion, ask for approval, and remove them in dependency order. | +| dev | `dev.md` | Run your stack in development mode with hot reloading — resources deploy to the cloud while Workers run in the local dev runtime. | +| drift | `drift.md` | Detect infrastructure drift by re-observing the cloud, and optionally repair it back to the last deployed state. | +| Inspecting State | `inspecting-state.md` | See what alchemy thinks is deployed, debug a bad diff, and recover from bad state. | +| logs | `logs.md` | Fetch or tail logs from deployed resources, merged and color-coded by resource. | +| nuke | `nuke.md` | Enumerate and delete every live resource across the stack's providers. | +| plan | `plan.md` | Preview what would change without applying anything. Equivalent to alchemy deploy --dry-run. | +| profile | `profile.md` | Create, refresh, rename, edit, inspect, or delete Alchemy authentication profiles. | +| state | `state.md` | Browse, read, and delete entries in the state store. | diff --git a/.agents/skills/alchemy/references/cli/_overview.md b/.agents/skills/alchemy/references/cli/_overview.md new file mode 100644 index 00000000..95662ae9 --- /dev/null +++ b/.agents/skills/alchemy/references/cli/_overview.md @@ -0,0 +1,90 @@ + + +# CLI + +> Every alchemy command at a glance — the command map, common options, and how the interactive TUI decides when to render. + +```sh +bun alchemy [options] +``` + +Every command operates on an `alchemy.run.ts` stack file (or a custom entrypoint passed with `--config `, which must exist) and targets a [stage](/environments/stages). + +## Command map + +```sh +alchemy +├─ deploy # plan → approve → apply +├─ plan # preview changes, apply nothing +├─ destroy # delete every resource in a stage +├─ drift # detect (and optionally repair) infrastructure drift +├─ unsafe nuke # delete everything a provider can list, tracked or not +│ +├─ dev # hot-reloading development loop +├─ logs # fetch past log entries, or tail live with --tail +│ +├─ profile create|rename|edit|refresh|current|list|show|delete +│ # manage credentials and accounts (bare `profile` opens the dashboard) +│ +├─ state list|read|delete # inspect and manage the state store (bare `state` opens the explorer) +│ +└─ provider + ├─ check-env # CI preflight: verify required provider env vars are set + ├─ aws bootstrap|teardown + └─ cloudflare bootstrap|teardown|token|state logs +``` + +Each command has its own page: [deploy](/cli/deploy), [plan](/cli/plan), [destroy](/cli/destroy), [drift](/cli/drift), [unsafe nuke](/cli/nuke), [dev](/cli/dev), [logs](/cli/logs), [profile](/cli/profile), [state](/cli/state), [AWS provider commands](/cli/aws), and [Cloudflare provider commands](/cli/cloudflare). The workflow guides cover [adopting resources](/cli/adopting-resources) and [inspecting state](/cli/inspecting-state). + +## Common options + +| Option | Description | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `--stage ` | Stage to target. Falls back to `$ALCHEMY_STAGE`, then `live_$USER` (`dev_$USER` for `alchemy dev`). Must match `[a-z0-9]+([-_a-z0-9]+)*`. | +| `--profile ` | Auth profile from `~/.alchemy/profiles.json`. Overrides `$ALCHEMY_PROFILE`; defaults to `default`. | +| `--env-file ` | File to load environment variables from. Defaults to `.env`. | +| `--yes` | Yes to all prompts. | +| `--no-input` | Disable prompts and the interactive TUI. Commands that would need input fail instead of hanging. | +| `--config, -c ` | Stack entrypoint. Defaults to `alchemy.run.ts`; must exist. | + +Use `--profile` with `deploy`, `plan`, `destroy`, `dev`, `logs`, `drift`, or `state`. + +## The interactive TUI + +In an interactive terminal, `deploy`, `destroy`, and `plan` render a live TUI: the rendered plan, an arrow-key approval prompt, and per-resource apply progress that unmounts into the final output. Everywhere else the same commands emit plain line-oriented logs. + +The `--no-input` flag and environment variables override the detection, in this order: + +| Signal | Effect | +| ------------------------------------------------------------------------------------------------- | ----------------- | +| `--no-input` flag | Force plain output | +| `ALCHEMY_PLAIN=1` or `ALCHEMY_NO_TUI=1` | Force plain output | +| `ALCHEMY_TUI=1` | Force the TUI | +| No TTY, `CI=1`, or a known agent env var (`CLAUDECODE`, `CLAUDE_CODE_ENTRYPOINT`, `CURSOR_AGENT`, `AIDER_MODEL`, `CODEX_CLI`) | Force plain output | + +Interactive terminals use the animated TUI; redirected and non-interactive runs use append-only output. + +Plain mode never prompts — it prints the plan, makes no changes, and fails with `error: Cannot approve this operation without terminal input. Pass --yes to continue.` In CI, always pass `--yes`. + +## Exit codes + +Exit codes are script-safe: `0` only means the command actually completed. + +| Code | Meaning | +| ----- | --------------------------------------------------------------------------------------------------- | +| `0` | Command completed | +| `1` | Failure, including a declined plan or bare `profile`/`state` without a terminal | +| `130` | Cancelled by the user (Ctrl-C, or Escape inside a prompt) | + +Running these commands in a pipeline? See [CI](/environments/ci). + +## Where next + +- [deploy](/cli/deploy) — plan, approve, and apply changes +- [dev](/cli/dev) — hot-reloading development loop +- [profile](/cli/profile) manages credentials and connected accounts +- [state](/cli/state) — inspect and manage the state store +- [Stages](/environments/stages) — how environments are isolated +- [CI](/environments/ci) — run the CLI in pipelines diff --git a/.agents/skills/alchemy/references/cli/adopting-resources.md b/.agents/skills/alchemy/references/cli/adopting-resources.md new file mode 100644 index 00000000..4537c6f2 --- /dev/null +++ b/.agents/skills/alchemy/references/cli/adopting-resources.md @@ -0,0 +1,96 @@ + + +# Adopting Resources + +> How alchemy takes ownership of pre-existing cloud resources — recovery by default, --adopt for foreign resources, and the programmatic AdoptPolicy. + +Two situations put alchemy in front of a cloud resource it has no state for: existing infrastructure you want alchemy to start managing, and a fresh (or wiped) state store sitting in front of resources alchemy already created. + +```sh +bun alchemy deploy --adopt +``` + +## How the engine decides + +When a resource has no prior state, the engine calls the provider's +`read` to check whether the resource already exists in the cloud. The +return value drives one of three paths: + +| `read` returns | Without `--adopt` | With `--adopt` | +| ------------------- | ------------------------- | --------------------- | +| `undefined` | create | create | +| owned (plain attrs) | silent adopt | silent adopt | +| `Unowned(attrs)` | fail `OwnedBySomeoneElse` | take over (silently) | + +"Owned" means the provider can prove the resource was created by *this* stack/stage/logical-id — typically by inspecting tags or a naming convention. + +The same table drives the provider-side contract: see [Adoption in Resource Lifecycle](/infrastructure-as-code/resource-lifecycle#adoption) for the engine's routing and [the `read` operation](/infrastructure-as-code/provider#read) for how a provider returns `Unowned(attrs)`. + +## Recovery is the default + +Re-importing owned resources into a fresh state store needs **no** flag — recovering a wiped state store for resources you already own is the default behavior. + +`--adopt` only matters when the provider reports a resource exists but isn't ours. That's the deliberately load-bearing case: by default alchemy refuses to silently overwrite tags/config of a resource it can't prove ownership of. `--adopt` says "yes, take it over." + +```sh +# Re-import existing alchemy-tagged resources into a fresh state +# store — no flag needed for these. Ones with foreign ownership tags +# will surface as `OwnedBySomeoneElse` errors. +alchemy deploy + +# Preview the takeover without applying it. +alchemy plan --adopt + +# Force takeover of any conflicting resource regardless of tags. +alchemy deploy --adopt +``` + +## The `--adopt` flag + +`--adopt` opts the *entire* deploy in to adoption-on-conflict — it is provided as a single `AdoptPolicy` layer over the whole stack execution, not per resource. + +The default is `false` so an unintentional collision still surfaces loudly instead of silently overwriting someone else's resource. The option is part of the shared `ExecStackOptions` that every stack-executing command (including `dev`) runs through. + +## When adoption is refused: `OwnedBySomeoneElse` + +When `read` reports an existing resource as `Unowned` and adoption is off, the deploy fails with the typed `OwnedBySomeoneElse` error, carrying `message`, `resourceType`, `logicalId`, and `physicalName`. + +You have three ways out: re-tag the cloud resource so the provider recognizes it as yours, pick a different physical name so there is no collision, or re-run with `--adopt` (or `adopt(true)`) to force a takeover. + +## Programmatic adoption + +You can also enable adoption programmatically by piping the `adopt` combinator onto a deploy effect from `alchemy/AdoptPolicy`: + +```typescript +import { adopt } from "alchemy/AdoptPolicy"; + +yield* deploy( + Effect.gen(function* () { + yield* Cloudflare.Worker("API", { /* ... */ }); + }), +).pipe(adopt(true)); +``` + +`AdoptPolicy` is consulted at plan time, so the combinator must wrap the *deploy* (or test runner's `stack.deploy(...)`) — not the inner resource-declaration effect. + +`adopt()` with no argument means `true`, and it also accepts an `Effect` for dynamic policies: + +```typescript +deployEffect.pipe(adopt()); // same as adopt(true) +deployEffect.pipe(adopt(decidePolicy)); // Effect, resolved at plan time +``` + +## Resources without ownership semantics + +Providers with no tag concept (e.g. APIs that always return a singleton by name) always return plain attrs from `read`, so they silently adopt unconditionally — `--adopt` is a no-op for them. + +For the full lost-state-store recovery walkthrough (state explorer → state delete → deploy), see [Inspecting State](/cli/inspecting-state). + +## Where next + +- [deploy](/cli/deploy) — the command that carries `--adopt` +- [Inspecting State](/cli/inspecting-state) — recover a lost or wiped state store +- [Resource Lifecycle](/infrastructure-as-code/resource-lifecycle) — where adoption sits in the engine +- [Providers](/infrastructure-as-code/provider) — implementing `read` and `Unowned` diff --git a/.agents/skills/alchemy/references/cli/aws.md b/.agents/skills/alchemy/references/cli/aws.md new file mode 100644 index 00000000..347317f7 --- /dev/null +++ b/.agents/skills/alchemy/references/cli/aws.md @@ -0,0 +1,80 @@ + + +# aws + +> AWS provider commands — bootstrap the per-account assets bucket that Lambda deployments rely on. + +```sh +alchemy provider aws [options] +``` + +Cloud-provider commands for AWS — managing the per-account +infrastructure that Alchemy itself relies on. + +## `provider aws bootstrap` + +```sh +alchemy provider aws bootstrap [options] +``` + +Set up the AWS assets bucket required for deploying Lambda functions +and other AWS resources that need artifact storage. + +```text +✓ Created assets bucket: alchemy-assets-123456789012-us-west-2-an +``` + +Re-running is a no-op: + +```text +✓ Assets bucket already exists: alchemy-assets-123456789012-us-west-2-an +``` + +| Option | Description | +| ---------------------- | -------------------------------------------------------------------------------- | +| `--aws-profile ` | AWS CLI/SSO profile to use for credentials (default: `default`) | +| `--region ` | AWS region to bootstrap. Defaults to the SSO profile's region, then `us-east-1`. | +| `--env-file ` | Load environment variables from a file | + +Bootstrap requires an AWS **SSO profile** with `sso_account_id` set — +it fails with a clear error if the profile is missing one. + +Logs are written to `.alchemy/log/bootstrap.txt`. + +## `provider aws teardown` + +```sh +alchemy provider aws teardown [options] +``` + +Removes every bootstrap bucket in the selected region and reports the +count. It accepts the same profile, region, and environment-file options as +`bootstrap`, plus `--yes` to skip confirmation. + +```text +✓ Destroyed 1 bootstrap bucket(s): alchemy-assets-123456789012-us-west-2-an +``` + +If there's nothing to remove: + +```text +✓ No bootstrap buckets found to destroy +``` + +```sh +# Bootstrap with the default profile +alchemy provider aws bootstrap + +# Bootstrap a specific region and profile +alchemy provider aws bootstrap --aws-profile prod --region us-west-2 + +# Remove bootstrap resources +alchemy provider aws teardown +``` + +## Where next + +- [cloudflare](/cli/cloudflare) — provider commands for Cloudflare +- [AWS](/aws) — the AWS provider hub diff --git a/.agents/skills/alchemy/references/cli/cloudflare.md b/.agents/skills/alchemy/references/cli/cloudflare.md new file mode 100644 index 00000000..c0c35e66 --- /dev/null +++ b/.agents/skills/alchemy/references/cli/cloudflare.md @@ -0,0 +1,190 @@ + + +# cloudflare + +> Cloudflare provider commands — bootstrap the state-store worker, mint API tokens, and inspect state-store logs. + +```sh +alchemy provider cloudflare [options] +``` + +Cloud-provider commands for Cloudflare — managing the state-store +worker that backs `Cloudflare.state(...)`, and inspecting its logs. + +## `provider cloudflare bootstrap` + +```sh +alchemy provider cloudflare bootstrap [options] +``` + +Manually deploy (or repair) the Cloudflare-hosted HTTP State Store — +the worker + Secrets Store + auth-token secret that back the +remote-state layer used by `Cloudflare.state(...)`. + +You normally don't need to run this: the very first stack deploy +that uses `Cloudflare.state(...)` will prompt you to bootstrap +automatically. Use this command to re-run that flow on demand — +typically to recover from a previous deploy that was interrupted +mid-bootstrap. + +| Option | Description | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `--profile ` | Alchemy auth profile (defaults to `$ALCHEMY_PROFILE` or `default`). Determines which `~/.alchemy/profiles.json` entry is used. | +| `--force` | Force a full redeploy even if the worker already exists. Without this flag, an existing worker is **adopted** and only its credentials are refreshed. | +| `--worker-name ` | Override the default state-store worker name. Advanced; only needed if you run multiple state stores per Cloudflare account. | +| `--env-file ` | Load environment variables from a file | + +The bootstrap is idempotent and self-healing: + +- An existing worker is **adopted** — its auth token is re-fetched live and `~/.alchemy/credentials//cloudflare-state-store.json` is rewritten with the current token. +- A partial failure resumes from the leftover local state stack; the bootstrap only completes once local state has been hoisted into the remote store. +- `--force` redeploys unconditionally, but still reconciles existing resources (worker, Secrets Store, secret) in place rather than replacing them. + +```sh +# First-time bootstrap (or repair after a failed deploy) +alchemy provider cloudflare bootstrap + +# Bootstrap a separate profile +alchemy provider cloudflare bootstrap --profile staging + +# Force a full redeploy (e.g. to roll out an updated state-store worker) +alchemy provider cloudflare bootstrap --force +``` + +## `provider cloudflare teardown` + +```sh +alchemy provider cloudflare teardown [options] +``` + +Tear down the state-store Worker and its backing resources. It accepts +`--profile`, `--worker-name`, and `--env-file`, plus `--yes` to skip the +destructive confirmation. + +## `provider cloudflare token` + +```sh +alchemy provider cloudflare token [options] +``` + +Mint a Cloudflare API token (`POST /user/tokens`) from the CLI. Useful +for creating the `CLOUDFLARE_API_TOKEN` you hand to CI, or a broad local +token for `alchemy deploy`. You'll be pointed to +[your Cloudflare profile](https://dash.cloudflare.com/profile/api-tokens) +once to grab your Global API Key (unless it's already in the environment); +everything else happens in the terminal. + +This command is **standalone** — it takes no `--profile` and does not +use an Alchemy auth profile. Cloudflare only mints a token whose +permissions the authenticating credential is allowed to grant, and +OAuth/scoped tokens silently produce a token with **zero** permissions. +So this command always authenticates with your account's **Global API +Key**, read from `CLOUDFLARE_API_KEY` / `CLOUDFLARE_EMAIL` (or +prompted). The key is used only to create the token and is never stored. + +By default you pick permission groups from an interactive multi-select, +resolved live from your account — restricted to the three scopes that +can actually become a token policy (account, zone, r2). Pass +`--all-permissions` for a "superuser" token spanning **every** permission +group your account exposes (after a confirmation prompt). + +```text +◇ Select a Cloudflare account +│ My Account +│ +◇ Token name +│ alchemy-superuser +│ +◇ Create a superuser token with all permissions? +│ Yes + +Created Cloudflare API token "alchemy-superuser" (7cb070fe...). +Granted 360 permission group(s) across 3 policy(ies); token status: active. + +cfut_pMtBzTOGHWyFC2RSNvfw8f7p2cc... + +Store this value now — Cloudflare only shows it once. Use it as CLOUDFLARE_API_TOKEN. +``` + +| Option | Description | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `--all-permissions` | Grant every Cloudflare permission group (a "superuser" token). Prompts for confirmation. | +| `--name ` | Token name. Prompted (required) if omitted. | +| `--account-id ` | Account ID(s) to scope the token to — comma-separated for multiple. If omitted, you'll be prompted with a multi-select spanning your accounts. | +| `--env-file ` | Load environment variables (e.g. `CLOUDFLARE_API_KEY`, `CLOUDFLARE_EMAIL`) from a file | + +The permission groups are resolved **live** from your account (so the +token always uses IDs valid for that account), and the freshly minted +token is verified against `/user/tokens/verify` before the value is +printed. + +If the result reports `Granted 0 permission group(s)`, the token is +useless: a token can only carry permissions the authenticating user +already holds, so this usually means the Global API Key's user is not +a Super Administrator on the selected account. Check the user's role +under Members in the Cloudflare dashboard and retry. + +:::note +The Cloudflare dashboard has a long-standing rendering bug where +API-created tokens show a **blank** permission summary (and a greyed-out +"View") on first load. The permissions are applied regardless — the +`Granted N permission group(s)` line and `token status: active` confirm +it. To see them in the UI, open the token and click "← Edit token". +::: + +```sh +# Pick permission groups interactively for a typical Alchemy deploy token +alchemy provider cloudflare token --name ci-token + +# Full-access "superuser" token +alchemy provider cloudflare token --all-permissions --name admin + +# Non-interactive: key/email from the environment, explicit account +CLOUDFLARE_API_KEY=... CLOUDFLARE_EMAIL=you@example.com \ + alchemy provider cloudflare token --name ci --account-id +``` + +## `provider cloudflare state logs` + +```sh +alchemy provider cloudflare state logs [options] +``` + +Get or tail logs from the `alchemy-state-store` Worker on your +Cloudflare account. Talks directly to the Workers Observability +Telemetry API — no stack file required — so you can debug the +state-store itself. + +Requires `workers_observability:read` and +`workers_observability_telemetry:write` OAuth scopes (included in the +default scope set; existing profiles need to re-run +`alchemy profile edit --reconfigure Cloudflare` to pick them up). + +| Option | Description | +| ---------------------- | ------------------------------------------------------------------------------------------------------------ | +| `--tail`, `-t` | Stream logs in real time via the Cloudflare tail websocket instead of fetching past entries | +| `--limit ` | Number of log entries to fetch (default: 100). Ignored with `--tail`. | +| `--since