| 1 | --- |
| 2 | title: Lambda |
| 3 | description: Stand up an AWS Lambda Function from a single Effect, expose it over a Function URL, and call it from a test. |
| 4 | --- |
| 5 | |
| 6 | **Lambda** is Alchemy's default AWS runtime: a class that bundles |
| 7 | an Effect program into a zip, deploys it as a Lambda Function, |
| 8 | and generates its IAM execution role from the bindings you |
| 9 | actually use. Serve HTTP over a public **Function URL**, or |
| 10 | consume events — every building block in this section plugs in |
| 11 | through the same pattern: S3 notifications |
| 12 | (`Lambda.BucketEventSource`), SQS queues |
| 13 | (`Lambda.QueueEventSource`), Kinesis streams |
| 14 | (`Lambda.StreamEventSource`), and DynamoDB Streams |
| 15 | (`Lambda.TableEventSource`). |
| 16 | |
| 17 | This page stands up the smallest possible function — a public |
| 18 | HTTP endpoint — which the [S3](/aws/data/s3), |
| 19 | [DynamoDB](/aws/data/dynamodb), [SQS](/aws/messaging/sqs), and |
| 20 | [Kinesis](/aws/messaging/kinesis) pages then grow into a small |
| 21 | event-driven system. |
| 22 | |
| 23 | ## Prerequisites |
| 24 | |
| 25 | Install Alchemy and connect your AWS account — see |
| 26 | [Setup](/aws/setup). |
| 27 | |
| 28 | ## Create the Stack |
| 29 | |
| 30 | Every Alchemy app has an `alchemy.run.ts` at the root that |
| 31 | declares the resources to deploy. Create one with the AWS |
| 32 | providers wired in: |
| 33 | |
| 34 | ```typescript |
| 35 | // alchemy.run.ts |
| 36 | import * as Alchemy from "alchemy"; |
| 37 | import * as AWS from "alchemy/AWS"; |
| 38 | import * as Effect from "effect/Effect"; |
| 39 | |
| 40 | export default Alchemy.Stack( |
| 41 | "MyApp", |
| 42 | { |
| 43 | providers: AWS.providers(), |
| 44 | state: AWS.state(), |
| 45 | }, |
| 46 | Effect.gen(function* () { |
| 47 | return {}; |
| 48 | }), |
| 49 | ); |
| 50 | ``` |
| 51 | |
| 52 | `Alchemy.Stack` is the root of every app. The first argument |
| 53 | (`"MyApp"`) doubles as a logical id and as the prefix Alchemy |
| 54 | uses when AWS asks for physical resource names. |
| 55 | |
| 56 | `providers: AWS.providers()` registers every AWS resource and |
| 57 | IAM policy binding that ships with Alchemy, and resolves |
| 58 | credentials from your Alchemy profile — SSO, environment |
| 59 | variables, or stored keys, whichever you picked in |
| 60 | [Setup](/aws/setup). |
| 61 | |
| 62 | `state: AWS.state()` stores deploy state in an account-regional |
| 63 | S3 bucket (`alchemy-state-{accountId}-{region}-an`), created |
| 64 | lazily on the first deploy. Because the state lives in your AWS |
| 65 | account rather than on disk, the same configuration works |
| 66 | locally and in CI. (For purely local iteration you can use |
| 67 | `Alchemy.localState()` instead, which writes state under |
| 68 | `.alchemy/` next to your code.) |
| 69 | |
| 70 | The trailing `Effect.gen` block is where you'll declare the |
| 71 | resources to deploy. It's empty for now. |
| 72 | |
| 73 | ## Declare a Lambda Function |
| 74 | |
| 75 | A Lambda Function in Alchemy is a class. Create `src/api.ts` |
| 76 | with the smallest possible declaration — just the class |
| 77 | ceremony, an entrypoint, and an empty runtime: |
| 78 | |
| 79 | ```typescript |
| 80 | // src/api.ts |
| 81 | import * as AWS from "alchemy/AWS"; |
| 82 | import * as Effect from "effect/Effect"; |
| 83 | |
| 84 | export default class Api extends AWS.Lambda.Function<Api>()( |
| 85 | "Api", |
| 86 | { main: import.meta.url }, |
| 87 | Effect.gen(function* () { |
| 88 | return {}; |
| 89 | }), |
| 90 | ) {} |
| 91 | ``` |
| 92 | |
| 93 | The `<Api>` type argument plus the empty `()` is a one-time bit |
| 94 | of ceremony — it lets TypeScript reason about `Api` as a typed |
| 95 | handle that other resources can later bind against. The rest of |
| 96 | your code looks completely normal. |
| 97 | |
| 98 | `main: import.meta.url` tells Alchemy this same file is the |
| 99 | bundle entrypoint. At deploy time it'll be bundled with Rolldown |
| 100 | into a zip and uploaded as the function's code. |
| 101 | |
| 102 | ## Serve HTTP from a Function URL |
| 103 | |
| 104 | The empty function compiles, but it doesn't do anything yet. |
| 105 | Add a `fetch` field — Alchemy treats anything returned from the |
| 106 | `Effect.gen` block as the runtime API, and `fetch` specifically |
| 107 | is wired up to handle incoming HTTP requests: |
| 108 | |
| 109 | ```diff lang="typescript" |
| 110 | // src/api.ts |
| 111 | import * as AWS from "alchemy/AWS"; |
| 112 | import * as Effect from "effect/Effect"; |
| 113 | +import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 114 | |
| 115 | export default class Api extends AWS.Lambda.Function<Api>()( |
| 116 | "Api", |
| 117 | { main: import.meta.url }, |
| 118 | Effect.gen(function* () { |
| 119 | - return {}; |
| 120 | + return { |
| 121 | + fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), |
| 122 | + }; |
| 123 | }), |
| 124 | ) {} |
| 125 | ``` |
| 126 | |
| 127 | `HttpServerResponse.text(...)` is the same `effect/http` |
| 128 | API used everywhere else — Alchemy adapts it to the Lambda event |
| 129 | envelope under the hood, so your handler never sees the raw |
| 130 | `APIGatewayProxyEvent` shape. |
| 131 | |
| 132 | ## Expose a public URL |
| 133 | |
| 134 | `fetch` exists, but no one can call it yet. Set `functionUrl: true` on |
| 135 | the props to ask AWS for a public **Function URL** — no API |
| 136 | Gateway, no auth, just a public HTTPS endpoint: |
| 137 | |
| 138 | ```diff lang="typescript" |
| 139 | export default class Api extends AWS.Lambda.Function<Api>()( |
| 140 | "Api", |
| 141 | - { main: import.meta.url }, |
| 142 | + { main: import.meta.url, functionUrl: true }, |
| 143 | Effect.gen(function* () { |
| 144 | return { |
| 145 | fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), |
| 146 | }; |
| 147 | }), |
| 148 | ) {} |
| 149 | ``` |
| 150 | |
| 151 | The resolved `Api` resource will now expose a `functionUrl` |
| 152 | field carrying that endpoint — we'll surface it from the Stack |
| 153 | in a moment. |
| 154 | |
| 155 | ## Customize per stage |
| 156 | |
| 157 | Lambda has knobs you'll want to tune per stage — memory, |
| 158 | timeout, log retention. Swap the static props object for |
| 159 | `Stack.useSync`, which gives you a synchronous accessor for any |
| 160 | value in the surrounding Effect context: |
| 161 | |
| 162 | ```diff lang="typescript" |
| 163 | // src/api.ts |
| 164 | import * as AWS from "alchemy/AWS"; |
| 165 | +import { Stack } from "alchemy/Stack"; |
| 166 | import * as Effect from "effect/Effect"; |
| 167 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 168 | |
| 169 | export default class Api extends AWS.Lambda.Function<Api>()( |
| 170 | "Api", |
| 171 | - { main: import.meta.url, functionUrl: true }, |
| 172 | + Stack.useSync((stack) => ({ |
| 173 | + main: import.meta.url, |
| 174 | + functionUrl: true, |
| 175 | + memory: stack.stage === "prod" ? 1024 : 512, |
| 176 | + })), |
| 177 | Effect.gen(function* () { |
| 178 | return { |
| 179 | fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), |
| 180 | }; |
| 181 | }), |
| 182 | ) {} |
| 183 | ``` |
| 184 | |
| 185 | `Stack.useSync` is the synchronous accessor for any value in the |
| 186 | surrounding Effect context — handy for stack-level config like |
| 187 | `stage`, `appName`, or anything else you'd want to vary per |
| 188 | environment. |
| 189 | |
| 190 | ## Wire the function into the Stack |
| 191 | |
| 192 | The `Api` class is just a typed identifier — yielding it inside |
| 193 | the Stack's Effect is what registers the resource and starts the |
| 194 | deploy: |
| 195 | |
| 196 | ```diff lang="typescript" |
| 197 | // alchemy.run.ts |
| 198 | import * as Alchemy from "alchemy"; |
| 199 | import * as AWS from "alchemy/AWS"; |
| 200 | import * as Effect from "effect/Effect"; |
| 201 | +import Api from "./src/api.ts"; |
| 202 | |
| 203 | export default Alchemy.Stack( |
| 204 | "MyApp", |
| 205 | { |
| 206 | providers: AWS.providers(), |
| 207 | state: AWS.state(), |
| 208 | }, |
| 209 | Effect.gen(function* () { |
| 210 | + const api = yield* Api; |
| 211 | + return { url: api.functionUrl }; |
| 212 | - return {}; |
| 213 | }), |
| 214 | ); |
| 215 | ``` |
| 216 | |
| 217 | Yielding `Api` returns the resolved Lambda outputs — the function |
| 218 | ARN, role ARN, log group, and the public Function URL we asked |
| 219 | for with `functionUrl: true`. We surface `functionUrl` as the Stack's |
| 220 | `url` so the test harness can find it. |
| 221 | |
| 222 | ## Deploy |
| 223 | |
| 224 | ```sh |
| 225 | bun alchemy deploy |
| 226 | ``` |
| 227 | |
| 228 | Alchemy bundles `src/api.ts` with Rolldown, packages it into a |
| 229 | zip, creates the IAM execution role, uploads the function, and |
| 230 | provisions the Function URL. The first deploy takes a moment |
| 231 | because of role propagation; subsequent deploys are seconds. |
| 232 | |
| 233 | ## Verify |
| 234 | |
| 235 | Drop a quick integration test that hits the Function URL and |
| 236 | checks the body: |
| 237 | |
| 238 | ```typescript |
| 239 | // test/integ.test.ts |
| 240 | import * as AWS from "alchemy/AWS"; |
| 241 | import * as Test from "alchemy/Test/Bun"; |
| 242 | import { expect } from "bun:test"; |
| 243 | import * as Effect from "effect/Effect"; |
| 244 | import * as HttpClient from "effect/http/HttpClient"; |
| 245 | import Stack from "../alchemy.run.ts"; |
| 246 | |
| 247 | const { test, beforeAll, deploy } = Test.make({ |
| 248 | providers: AWS.providers(), |
| 249 | state: AWS.state(), |
| 250 | }); |
| 251 | |
| 252 | const stack = beforeAll(deploy(Stack)); |
| 253 | |
| 254 | test( |
| 255 | "Api responds over Function URL", |
| 256 | Effect.gen(function* () { |
| 257 | const { url } = yield* stack; |
| 258 | const response = yield* HttpClient.get(url); |
| 259 | expect(yield* response.text).toBe("Hello from Lambda!"); |
| 260 | }), |
| 261 | ); |
| 262 | ``` |
| 263 | |
| 264 | ```sh |
| 265 | bun test test/integ.test.ts |
| 266 | ``` |
| 267 | |
| 268 | You now have a deployable Lambda with a public URL. |
| 269 | |
| 270 | ## Sandbox scope vs invocation scope |
| 271 | |
| 272 | The constructor runs **once per sandbox**, at cold start — |
| 273 | everything it builds (bindings, SDK clients) is reused by every |
| 274 | invocation. Each invocation then runs with a **fresh `Scope`** that |
| 275 | is settled *inline* before the handler returns — so |
| 276 | `Effect.addFinalizer` in a handler runs before the response leaves, |
| 277 | and request finalizers should be fast (closing a pool is |
| 278 | milliseconds). |
| 279 | |
| 280 | :::caution[There is no `waitUntil` on Lambda — request finalizers block the response] |
| 281 | No mechanism delivers post-response work reliably on Lambda. A |
| 282 | buffered invocation's response is not released to the caller until |
| 283 | the entire Invoke phase completes, so deferring cleanup past the |
| 284 | handler (we measured an extension-held post-response window) still |
| 285 | shows up as response latency — and environments whose invoke phase |
| 286 | is held open this way get recycled. Dangling promises resume on a |
| 287 | later thaw at best: TCP connections rarely survive the freeze, and a |
| 288 | crash or timeout reset drops the work entirely — silent data loss |
| 289 | for anything that mattered. |
| 290 | |
| 291 | The contract is blocking and honest: request finalizers run before |
| 292 | the response, keep them cheap, and anything that must not be lost |
| 293 | gets written durably (a queue, a table) inside the handler itself. |
| 294 | ::: |
| 295 | |
| 296 | Construction-level finalizers run at **sandbox shutdown**. A Lambda sandbox |
| 297 | with no registered extensions is killed with no signal at all, so |
| 298 | the generated entry registers an internal extension with the |
| 299 | Extensions API — that makes Lambda send `SIGTERM` and allow 500 ms |
| 300 | before `SIGKILL`, and the entry uses the window to close the |
| 301 | instance scope: |
| 302 | |
| 303 | ```typescript |
| 304 | Effect.gen(function* () { |
| 305 | // Construction: runs once per sandbox |
| 306 | yield* Effect.addFinalizer(() => |
| 307 | // runs in the 500 ms SIGTERM window at sandbox spin-down |
| 308 | flushTelemetry().pipe(Effect.ignore), |
| 309 | ); |
| 310 | return { fetch: /* per-invocation */ }; |
| 311 | }); |
| 312 | ``` |
| 313 | |
| 314 | Treat instance-level cleanup as best-effort: half a second, and not |
| 315 | delivered on hard failures (a timeout reset kills the runtime |
| 316 | without the signal). Flush caches and close connections there; |
| 317 | anything that *must* happen belongs in the handler, scoped to the |
| 318 | invocation. See |
| 319 | [Instance scope vs request scope](/infrastructure-as-effects/runtime#instance-scope-vs-request-scope) |
| 320 | for the model across all runtimes. |
| 321 | |
| 322 | ## Where next |
| 323 | |
| 324 | - [Drizzle + Aurora PostgreSQL](/aws/data/drizzle-aurora) — private database access, |
| 325 | IAM authentication, verified TLS, and deployment-time schema setup. |
| 326 | - [Drizzle + Aurora DSQL](/aws/data/drizzle-dsql) — the separate serverless database, |
| 327 | with an IAM-authenticated endpoint and non-admin application role. |
| 328 | |
| 329 | - [S3](/aws/data/s3) — add a bucket and bind read/write operations |
| 330 | into this function; IAM policies generated from the call |
| 331 | sites, no policy JSON to hand-write. |
| 332 | - [Secrets & env](/aws/security/secrets-env) — API keys from .env and |
| 333 | Secrets Manager credentials for this function. |
| 334 | - [REST API (API Gateway v1)](/aws/apis/api-gateway) — put an |
| 335 | API Gateway REST API with stages and custom domains in front |
| 336 | instead of a Function URL. |
| 337 | - [Choosing a runtime](/aws/compute/choosing-a-runtime) — when ECS, |
| 338 | EKS, or EC2 fits better than Lambda. |
| 339 | - [`Function` reference](/providers/aws/lambda/function) — every |
| 340 | prop and attribute. |