1---
2title: "Part 2: Add a Lambda"
3description: Create an AWS Lambda Function with a public URL, bind the S3 Bucket, and implement GET/PUT routes.
4sidebar:
5 order: 2
6---
7
8import Terminal from "../../../../components/Terminal.astro";
9import { Code, Tabs, TabItem } from "@astrojs/starlight/components";
10
11In [Part 1](/aws/tutorial/part-1) you deployed an S3 Bucket. Now
12you'll create a Lambda Function with a public URL that reads and
13writes objects in that bucket over HTTP.
14
15## Create the function file
16
17A Lambda Function in Alchemy is a class — it has both an
18infrastructure definition and a runtime implementation expressed as
19an Effect. Create `src/api.ts` with the smallest possible
20declaration:
21
22```typescript
23// src/api.ts
24import * as AWS from "alchemy/AWS";
25import * as Effect from "effect/Effect";
26
27export 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
36The `<Api>` type argument plus the empty `()` is a one-time bit of
37ceremony — it lets TypeScript reason about `Api` as a typed handle
38that other resources can later bind against. `main: import.meta.url`
39tells Alchemy this same file is the bundle entrypoint: at deploy time
40it's bundled with Rolldown into a zip and uploaded as the function's
41code.
42
43## Serve HTTP with `fetch`
44
45The 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
48wired up to handle incoming HTTP requests:
49
50```diff lang="typescript"
51// src/api.ts
52import * as AWS from "alchemy/AWS";
53import * as Effect from "effect/Effect";
54+import * as HttpServerResponse from "effect/http/HttpServerResponse";
55
56export 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
69used everywhere else in Effect — Alchemy adapts it to the Lambda
70event envelope under the hood, so your handler never sees the raw
71`APIGatewayProxyEvent` shape.
72
73:::note
74Notice the "Effect returning an Effect" pattern. The outer generator
75is the **Construction phase** — it runs at both plan time and runtime. The
76inner `fetch` generator is the **Runtime phase** — it runs only when
77handling 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
83props to ask AWS for a public **Function URL** — no API Gateway, no
84auth, just a public HTTPS endpoint:
85
86```diff lang="typescript"
87export 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
99The resolved `Api` resource will now expose a `functionUrl` field
100carrying that endpoint — we'll surface it from the Stack in a moment.
101
102## Move the Bucket into the function
103
104In Part 1 the Bucket lived in `alchemy.run.ts`, but resources can be
105declared inside any Effect the Stack runs — including a function's
106Construction phase. Declaring the bucket next to the code that uses it keeps
107everything about the function in one file. Add it to the constructor:
108
109```diff lang="typescript"
110// src/api.ts
111import * as AWS from "alchemy/AWS";
112+import * as S3 from "alchemy/AWS/S3";
113import * as Effect from "effect/Effect";
114import * as HttpServerResponse from "effect/http/HttpServerResponse";
115
116export 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
129The logical id is still `Bucket`, so Alchemy recognizes it as the
130same resource you deployed in Part 1 — moving a declaration between
131files doesn't recreate anything.
132
133## Bind PutObject and GetObject
134
135S3 operations like `s3:PutObject` and `s3:GetObject` are exposed as
136**bindings** — a typed runtime function you call from your handler,
137plus an IAM policy statement that gets attached to the function role
138automatically, scoped to the exact bucket ARN. Bind them in the outer
139constructor, 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
153Both capability calls return a callable Effect: `putObject` takes a
154`PutObjectRequest` (the AWS SDK shape, minus the `Bucket` field
155Alchemy fills in for you) and `getObject` takes a `GetObjectRequest`.
156Each call is 1:1 with an IAM statement on the execution role — delete
157a capability call and the matching statement disappears next deploy.
158See [S3 → One binding, one IAM statement](/aws/data/s3#one-binding-one-iam-statement)
159for the exact mapping.
160
161## Write objects with `PUT /:key`
162
163Replace the static handler with one that takes the URL path as the
164object key and writes the request body to the bucket:
165
166```diff lang="typescript"
167// src/api.ts
168import * as AWS from "alchemy/AWS";
169import * as S3 from "alchemy/AWS/S3";
170import * as Effect from "effect/Effect";
171+import { HttpServerRequest } from "effect/http/HttpServerRequest";
172import * 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
193AWS SDK, minus the `Bucket` field — Alchemy fills that in from the
194bucket you bound, so the call site stays focused on what's actually
195variable.
196
197## Read objects with `GET /:key`
198
199Add a `GET` branch that fetches the object and pipes it back to the
200client:
201
202```diff lang="typescript"
203if (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
214return 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
219response as it arrives, so even a multi-gigabyte object moves through
220the function without ever being held in memory.
221
222## Handle missing objects
223
224If the key doesn't exist, S3 responds with `NoSuchKey`. The binding
225surfaces that as a typed error tag on the Effect, so
226`Effect.catchTag` recovers from it without inspecting strings:
227
228```diff lang="typescript"
229if (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
241Every binding's failure channel is enumerated this way: the AWS SDK
242error names become Effect tags, so the type-checker tells you which
243failures the call site needs to handle.
244
245## Convert unhandled errors to 500s
246
247Any failure you didn't `catchTag` is a programmer error — a missing
248IAM grant, a transient AWS outage, a bug. Surface those as 500s by
249applying `Effect.orDie` once at the request boundary, rather than per
250call:
251
252```diff lang="typescript"
253fetch: Effect.gen(function* () {
254 // ... PUT and GET branches ...
255 return HttpServerResponse.text("Method not allowed", { status: 405 });
256-}),
257+}).pipe(Effect.orDie),
258```
259
260Applying `orDie` once at the outer layer keeps the inner code free of
261repetitive error plumbing — the only errors you handle explicitly are
262the ones you actually have a recovery for (`NoSuchKey`, in this
263case).
264
265## Provide the runtime layers
266
267Bindings declare a capability; **runtime layers** implement it. For
268S3 those are `S3.PutObjectHttp` and `S3.GetObjectHttp` — both ship in
269`alchemy/AWS/S3` and depend only on the AWS SDK and the ambient
270credentials. 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// ...
276export 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
294The `Api` class is just a typed identifier — yielding it inside the
295Stack's Effect is what registers the resource and starts the deploy:
296
297```diff lang="typescript"
298// alchemy.run.ts
299import * as Alchemy from "alchemy";
300import * as AWS from "alchemy/AWS";
301import * as Effect from "effect/Effect";
302+import Api from "./src/api.ts";
303
304export 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
322Yielding `Api` returns the resolved Lambda outputs — the function
323ARN, 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
328The 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
343The bucket is still part of the Stack — it's registered when `Api`'s
344Construction phase runs — it just isn't surfaced as a stack output anymore.
345
346## Deploy
347
348Deploy again. Alchemy detects the new function and the unchanged
349bucket:
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
371Proceed?
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
379Alchemy bundles `src/api.ts` with Rolldown, packages it into a zip,
380creates the IAM execution role with the two S3 statements, uploads
381the function, and provisions the Function URL. The first deploy takes
382a moment because of role propagation; subsequent deploys are seconds.
383
384## Try it out
385
386Use `curl` to write and read an object:
387
388```sh
389# Store an object
390curl -X PUT https://abc123xyz.lambda-url.us-east-1.on.aws/hello.txt \
391 -d 'Hello, world!'
392
393# Retrieve it
394curl https://abc123xyz.lambda-url.us-east-1.on.aws/hello.txt
395# → Hello, world!
396```
397
398## Recap
399
400You 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
408In [Part 3](/aws/tutorial/part-3), you'll write integration tests
409that deploy the stack and drive it over HTTP.
410
411:::tip[Want to start building now?]
412You 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
415first and circle back to testing, stages, and CI/CD when you need
416them.
417:::