| 1 | --- |
| 2 | title: "Part 2: Add a Lambda" |
| 3 | description: Create an AWS Lambda Function with a public URL, bind the S3 Bucket, and implement GET/PUT routes. |
| 4 | sidebar: |
| 5 | order: 2 |
| 6 | --- |
| 7 | |
| 8 | import Terminal from "../../../../components/Terminal.astro"; |
| 9 | import { Code, Tabs, TabItem } from "@astrojs/starlight/components"; |
| 10 | |
| 11 | In [Part 1](/aws/tutorial/part-1) you deployed an S3 Bucket. Now |
| 12 | you'll create a Lambda Function with a public URL that reads and |
| 13 | writes objects in that bucket over HTTP. |
| 14 | |
| 15 | ## Create the function file |
| 16 | |
| 17 | A Lambda Function in Alchemy is a class — it has both an |
| 18 | infrastructure definition and a runtime implementation expressed as |
| 19 | an Effect. Create `src/api.ts` with the smallest possible |
| 20 | declaration: |
| 21 | |
| 22 | ```typescript |
| 23 | // src/api.ts |
| 24 | import * as AWS from "alchemy/AWS"; |
| 25 | import * as Effect from "effect/Effect"; |
| 26 | |
| 27 | export default class Api extends AWS.Lambda.Function<Api>()( |
| 28 | "Api", |
| 29 | { main: import.meta.url }, |
| 30 | Effect.gen(function* () { |
| 31 | return {}; |
| 32 | }), |
| 33 | ) {} |
| 34 | ``` |
| 35 | |
| 36 | The `<Api>` type argument plus the empty `()` is a one-time bit of |
| 37 | ceremony — it lets TypeScript reason about `Api` as a typed handle |
| 38 | that other resources can later bind against. `main: import.meta.url` |
| 39 | tells Alchemy this same file is the bundle entrypoint: at deploy time |
| 40 | it's bundled with Rolldown into a zip and uploaded as the function's |
| 41 | code. |
| 42 | |
| 43 | ## Serve HTTP with `fetch` |
| 44 | |
| 45 | The empty function compiles, but it doesn't do anything yet. Add a |
| 46 | `fetch` field — Alchemy treats anything returned from the |
| 47 | `Effect.gen` block as the runtime API, and `fetch` specifically is |
| 48 | wired up to handle incoming HTTP requests: |
| 49 | |
| 50 | ```diff lang="typescript" |
| 51 | // src/api.ts |
| 52 | import * as AWS from "alchemy/AWS"; |
| 53 | import * as Effect from "effect/Effect"; |
| 54 | +import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 55 | |
| 56 | export default class Api extends AWS.Lambda.Function<Api>()( |
| 57 | "Api", |
| 58 | { main: import.meta.url }, |
| 59 | Effect.gen(function* () { |
| 60 | - return {}; |
| 61 | + return { |
| 62 | + fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), |
| 63 | + }; |
| 64 | }), |
| 65 | ) {} |
| 66 | ``` |
| 67 | |
| 68 | `HttpServerResponse.text(...)` is the same `effect/http` API |
| 69 | used everywhere else in Effect — Alchemy adapts it to the Lambda |
| 70 | event envelope under the hood, so your handler never sees the raw |
| 71 | `APIGatewayProxyEvent` shape. |
| 72 | |
| 73 | :::note |
| 74 | Notice the "Effect returning an Effect" pattern. The outer generator |
| 75 | is the **Construction phase** — it runs at both plan time and runtime. The |
| 76 | inner `fetch` generator is the **Runtime phase** — it runs only when |
| 77 | handling a request. |
| 78 | ::: |
| 79 | |
| 80 | ## Expose a public URL |
| 81 | |
| 82 | `fetch` exists, but no one can call it yet. Set `functionUrl: true` on the |
| 83 | props to ask AWS for a public **Function URL** — no API Gateway, no |
| 84 | auth, just a public HTTPS endpoint: |
| 85 | |
| 86 | ```diff lang="typescript" |
| 87 | export default class Api extends AWS.Lambda.Function<Api>()( |
| 88 | "Api", |
| 89 | - { main: import.meta.url }, |
| 90 | + { main: import.meta.url, functionUrl: true }, |
| 91 | Effect.gen(function* () { |
| 92 | return { |
| 93 | fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), |
| 94 | }; |
| 95 | }), |
| 96 | ) {} |
| 97 | ``` |
| 98 | |
| 99 | The resolved `Api` resource will now expose a `functionUrl` field |
| 100 | carrying that endpoint — we'll surface it from the Stack in a moment. |
| 101 | |
| 102 | ## Move the Bucket into the function |
| 103 | |
| 104 | In Part 1 the Bucket lived in `alchemy.run.ts`, but resources can be |
| 105 | declared inside any Effect the Stack runs — including a function's |
| 106 | Construction phase. Declaring the bucket next to the code that uses it keeps |
| 107 | everything about the function in one file. Add it to the constructor: |
| 108 | |
| 109 | ```diff lang="typescript" |
| 110 | // src/api.ts |
| 111 | import * as AWS from "alchemy/AWS"; |
| 112 | +import * as S3 from "alchemy/AWS/S3"; |
| 113 | import * as Effect from "effect/Effect"; |
| 114 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 115 | |
| 116 | export default class Api extends AWS.Lambda.Function<Api>()( |
| 117 | "Api", |
| 118 | { main: import.meta.url, functionUrl: true }, |
| 119 | Effect.gen(function* () { |
| 120 | + const bucket = yield* S3.Bucket("Bucket"); |
| 121 | |
| 122 | return { |
| 123 | fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), |
| 124 | }; |
| 125 | }), |
| 126 | ) {} |
| 127 | ``` |
| 128 | |
| 129 | The logical id is still `Bucket`, so Alchemy recognizes it as the |
| 130 | same resource you deployed in Part 1 — moving a declaration between |
| 131 | files doesn't recreate anything. |
| 132 | |
| 133 | ## Bind PutObject and GetObject |
| 134 | |
| 135 | S3 operations like `s3:PutObject` and `s3:GetObject` are exposed as |
| 136 | **bindings** — a typed runtime function you call from your handler, |
| 137 | plus an IAM policy statement that gets attached to the function role |
| 138 | automatically, scoped to the exact bucket ARN. Bind them in the outer |
| 139 | constructor, alongside the bucket: |
| 140 | |
| 141 | ```diff lang="typescript" |
| 142 | Effect.gen(function* () { |
| 143 | const bucket = yield* S3.Bucket("Bucket"); |
| 144 | + const putObject = yield* S3.PutObject(bucket); |
| 145 | + const getObject = yield* S3.GetObject(bucket); |
| 146 | |
| 147 | return { |
| 148 | fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), |
| 149 | }; |
| 150 | }), |
| 151 | ``` |
| 152 | |
| 153 | Both capability calls return a callable Effect: `putObject` takes a |
| 154 | `PutObjectRequest` (the AWS SDK shape, minus the `Bucket` field |
| 155 | Alchemy fills in for you) and `getObject` takes a `GetObjectRequest`. |
| 156 | Each call is 1:1 with an IAM statement on the execution role — delete |
| 157 | a capability call and the matching statement disappears next deploy. |
| 158 | See [S3 → One binding, one IAM statement](/aws/data/s3#one-binding-one-iam-statement) |
| 159 | for the exact mapping. |
| 160 | |
| 161 | ## Write objects with `PUT /:key` |
| 162 | |
| 163 | Replace the static handler with one that takes the URL path as the |
| 164 | object key and writes the request body to the bucket: |
| 165 | |
| 166 | ```diff lang="typescript" |
| 167 | // src/api.ts |
| 168 | import * as AWS from "alchemy/AWS"; |
| 169 | import * as S3 from "alchemy/AWS/S3"; |
| 170 | import * as Effect from "effect/Effect"; |
| 171 | +import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 172 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 173 | |
| 174 | // ... |
| 175 | return { |
| 176 | - fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")), |
| 177 | + fetch: Effect.gen(function* () { |
| 178 | + const request = yield* HttpServerRequest; |
| 179 | + const key = new URL(request.url).pathname.slice(1); |
| 180 | + |
| 181 | + if (request.method === "PUT") { |
| 182 | + const body = yield* request.text; |
| 183 | + yield* putObject({ Key: key, Body: body }); |
| 184 | + return HttpServerResponse.empty({ status: 204 }); |
| 185 | + } |
| 186 | + |
| 187 | + return HttpServerResponse.text("Method not allowed", { status: 405 }); |
| 188 | + }), |
| 189 | }; |
| 190 | ``` |
| 191 | |
| 192 | `putObject({ Key, Body })` is the same shape as `s3:PutObject` in the |
| 193 | AWS SDK, minus the `Bucket` field — Alchemy fills that in from the |
| 194 | bucket you bound, so the call site stays focused on what's actually |
| 195 | variable. |
| 196 | |
| 197 | ## Read objects with `GET /:key` |
| 198 | |
| 199 | Add a `GET` branch that fetches the object and pipes it back to the |
| 200 | client: |
| 201 | |
| 202 | ```diff lang="typescript" |
| 203 | if (request.method === "PUT") { |
| 204 | const body = yield* request.text; |
| 205 | yield* putObject({ Key: key, Body: body }); |
| 206 | return HttpServerResponse.empty({ status: 204 }); |
| 207 | } |
| 208 | |
| 209 | +if (request.method === "GET") { |
| 210 | + const result = yield* getObject({ Key: key }); |
| 211 | + return HttpServerResponse.stream(result.Body!); |
| 212 | +} |
| 213 | |
| 214 | return HttpServerResponse.text("Method not allowed", { status: 405 }); |
| 215 | ``` |
| 216 | |
| 217 | `result.Body` is an Effect `Stream<Uint8Array>`, not a buffered |
| 218 | `Buffer`. `HttpServerResponse.stream` flushes each chunk to the HTTP |
| 219 | response as it arrives, so even a multi-gigabyte object moves through |
| 220 | the function without ever being held in memory. |
| 221 | |
| 222 | ## Handle missing objects |
| 223 | |
| 224 | If the key doesn't exist, S3 responds with `NoSuchKey`. The binding |
| 225 | surfaces that as a typed error tag on the Effect, so |
| 226 | `Effect.catchTag` recovers from it without inspecting strings: |
| 227 | |
| 228 | ```diff lang="typescript" |
| 229 | if (request.method === "GET") { |
| 230 | - const result = yield* getObject({ Key: key }); |
| 231 | + const result = yield* getObject({ Key: key }).pipe( |
| 232 | + Effect.catchTag("NoSuchKey", () => Effect.succeed(undefined)), |
| 233 | + ); |
| 234 | + if (!result?.Body) { |
| 235 | + return HttpServerResponse.text("Not found", { status: 404 }); |
| 236 | + } |
| 237 | return HttpServerResponse.stream(result.Body!); |
| 238 | } |
| 239 | ``` |
| 240 | |
| 241 | Every binding's failure channel is enumerated this way: the AWS SDK |
| 242 | error names become Effect tags, so the type-checker tells you which |
| 243 | failures the call site needs to handle. |
| 244 | |
| 245 | ## Convert unhandled errors to 500s |
| 246 | |
| 247 | Any failure you didn't `catchTag` is a programmer error — a missing |
| 248 | IAM grant, a transient AWS outage, a bug. Surface those as 500s by |
| 249 | applying `Effect.orDie` once at the request boundary, rather than per |
| 250 | call: |
| 251 | |
| 252 | ```diff lang="typescript" |
| 253 | fetch: Effect.gen(function* () { |
| 254 | // ... PUT and GET branches ... |
| 255 | return HttpServerResponse.text("Method not allowed", { status: 405 }); |
| 256 | -}), |
| 257 | +}).pipe(Effect.orDie), |
| 258 | ``` |
| 259 | |
| 260 | Applying `orDie` once at the outer layer keeps the inner code free of |
| 261 | repetitive error plumbing — the only errors you handle explicitly are |
| 262 | the ones you actually have a recovery for (`NoSuchKey`, in this |
| 263 | case). |
| 264 | |
| 265 | ## Provide the runtime layers |
| 266 | |
| 267 | Bindings declare a capability; **runtime layers** implement it. For |
| 268 | S3 those are `S3.PutObjectHttp` and `S3.GetObjectHttp` — both ship in |
| 269 | `alchemy/AWS/S3` and depend only on the AWS SDK and the ambient |
| 270 | credentials. Provide them at the bottom of the function: |
| 271 | |
| 272 | ```diff lang="typescript" |
| 273 | // src/api.ts |
| 274 | +import * as Layer from "effect/Layer"; |
| 275 | // ... |
| 276 | export default class Api extends AWS.Lambda.Function<Api>()( |
| 277 | "Api", |
| 278 | { main: import.meta.url, functionUrl: true }, |
| 279 | Effect.gen(function* () { |
| 280 | /* ... bucket + bindings + fetch ... */ |
| 281 | - }), |
| 282 | + }).pipe( |
| 283 | + Effect.provide(Layer.mergeAll(S3.PutObjectHttp, S3.GetObjectHttp)), |
| 284 | + ), |
| 285 | ) {} |
| 286 | ``` |
| 287 | |
| 288 | `Layer.mergeAll` unions multiple layers into one — no order required |
| 289 | — and `Effect.provide` satisfies the binding requirements declared by |
| 290 | `PutObject` and `GetObject`. |
| 291 | |
| 292 | ## Wire the function into the Stack |
| 293 | |
| 294 | The `Api` class is just a typed identifier — yielding it inside the |
| 295 | Stack's Effect is what registers the resource and starts the deploy: |
| 296 | |
| 297 | ```diff lang="typescript" |
| 298 | // alchemy.run.ts |
| 299 | import * as Alchemy from "alchemy"; |
| 300 | import * as AWS from "alchemy/AWS"; |
| 301 | import * as Effect from "effect/Effect"; |
| 302 | +import Api from "./src/api.ts"; |
| 303 | |
| 304 | export default Alchemy.Stack( |
| 305 | "MyApp", |
| 306 | { |
| 307 | providers: AWS.providers(), |
| 308 | state: AWS.state(), |
| 309 | }, |
| 310 | Effect.gen(function* () { |
| 311 | const bucket = yield* AWS.S3.Bucket("Bucket"); |
| 312 | + const api = yield* Api; |
| 313 | |
| 314 | return { |
| 315 | bucketName: bucket.bucketName, |
| 316 | + url: api.functionUrl, |
| 317 | }; |
| 318 | }), |
| 319 | ); |
| 320 | ``` |
| 321 | |
| 322 | Yielding `Api` returns the resolved Lambda outputs — the function |
| 323 | ARN, role ARN, and the public Function URL we asked for with |
| 324 | `functionUrl: true`. We surface `functionUrl` as the Stack's `url` output. |
| 325 | |
| 326 | ## Retire the inline Bucket |
| 327 | |
| 328 | The Bucket is now declared inside `Api`'s constructor, so the copy in |
| 329 | `alchemy.run.ts` is redundant — remove it: |
| 330 | |
| 331 | ```diff lang="typescript" |
| 332 | Effect.gen(function* () { |
| 333 | - const bucket = yield* AWS.S3.Bucket("Bucket"); |
| 334 | const api = yield* Api; |
| 335 | |
| 336 | return { |
| 337 | - bucketName: bucket.bucketName, |
| 338 | url: api.functionUrl, |
| 339 | }; |
| 340 | }), |
| 341 | ``` |
| 342 | |
| 343 | The bucket is still part of the Stack — it's registered when `Api`'s |
| 344 | Construction phase runs — it just isn't surfaced as a stack output anymore. |
| 345 | |
| 346 | ## Deploy |
| 347 | |
| 348 | Deploy again. Alchemy detects the new function and the unchanged |
| 349 | bucket: |
| 350 | |
| 351 | <Tabs syncKey="pkgManager"> |
| 352 | <TabItem label="bun" icon="bun"> |
| 353 | <Code code="bun alchemy deploy" lang="sh" /> |
| 354 | </TabItem> |
| 355 | <TabItem label="npm" icon="npm"> |
| 356 | <Code code="npm run alchemy deploy" lang="sh" /> |
| 357 | </TabItem> |
| 358 | <TabItem label="pnpm" icon="pnpm"> |
| 359 | <Code code="pnpm alchemy deploy" lang="sh" /> |
| 360 | </TabItem> |
| 361 | <TabItem label="yarn" icon="seti:yarn"> |
| 362 | <Code code="yarn alchemy deploy" lang="sh" /> |
| 363 | </TabItem> |
| 364 | </Tabs> |
| 365 | |
| 366 | <Terminal content={`[u]Plan[/u]: [g]1 to create[/g] |
| 367 | |
| 368 | [g]+[/g] [b]Api[/b] [d](AWS.Lambda.Function)[/d] |
| 369 | [d]•[/d] [b]Bucket[/b] [d](AWS.S3.Bucket)[/d] |
| 370 | |
| 371 | Proceed? |
| 372 | ◉ Yes ○ No |
| 373 | [d]•[/d] [b]Bucket[/b] [d](AWS.S3.Bucket)[/d] [d]no change[/d] |
| 374 | [g]✓[/g] [b]Api[/b] [d](AWS.Lambda.Function)[/d] [g]created[/g] |
| 375 | { |
| 376 | [s2]url: "https://abc123xyz.lambda-url.us-east-1.on.aws", |
| 377 | }`} /> |
| 378 | |
| 379 | Alchemy bundles `src/api.ts` with Rolldown, packages it into a zip, |
| 380 | creates the IAM execution role with the two S3 statements, uploads |
| 381 | the function, and provisions the Function URL. The first deploy takes |
| 382 | a moment because of role propagation; subsequent deploys are seconds. |
| 383 | |
| 384 | ## Try it out |
| 385 | |
| 386 | Use `curl` to write and read an object: |
| 387 | |
| 388 | ```sh |
| 389 | # Store an object |
| 390 | curl -X PUT https://abc123xyz.lambda-url.us-east-1.on.aws/hello.txt \ |
| 391 | -d 'Hello, world!' |
| 392 | |
| 393 | # Retrieve it |
| 394 | curl https://abc123xyz.lambda-url.us-east-1.on.aws/hello.txt |
| 395 | # → Hello, world! |
| 396 | ``` |
| 397 | |
| 398 | ## Recap |
| 399 | |
| 400 | You now have: |
| 401 | |
| 402 | - A Lambda Function with GET and PUT routes served over a public |
| 403 | Function URL |
| 404 | - An S3 Bucket bound to the function, with the IAM policy generated |
| 405 | from the bindings you actually use — no policy JSON in sight |
| 406 | - Stack outputs showing the function URL |
| 407 | |
| 408 | In [Part 3](/aws/tutorial/part-3), you'll write integration tests |
| 409 | that deploy the stack and drive it over HTTP. |
| 410 | |
| 411 | :::tip[Want to start building now?] |
| 412 | You have a deployed function — that's enough to skip ahead to |
| 413 | [DynamoDB](/aws/data/dynamodb), [SQS](/aws/messaging/sqs), or |
| 414 | [Kinesis](/aws/messaging/kinesis) if you'd rather build something interesting |
| 415 | first and circle back to testing, stages, and CI/CD when you need |
| 416 | them. |
| 417 | ::: |