Skip to content

Part 2: Add a Lambda

In 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.

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:

src/api.ts
import * as AWS from "alchemy/AWS";
import * as Effect from "effect/Effect";
export default class Api extends AWS.Lambda.Function<Api>()(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
return {};
}),
) {}

The <Api> 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.

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:

src/api.ts
import * as AWS from "alchemy/AWS";
import * as Effect from "effect/Effect";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
export default class Api extends AWS.Lambda.Function<Api>()(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
return {};
return {
fetch: Effect.succeed(HttpServerResponse.text("Hello from Lambda!")),
};
}),
) {}

HttpServerResponse.text(...) is the same effect/unstable/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.

fetch exists, but no one can call it yet. Set url: true on the props to ask AWS for a public Function URL — no API Gateway, no auth, just a public HTTPS endpoint:

export default class Api extends AWS.Lambda.Function<Api>()(
"Api",
{ main: import.meta.url },
{ main: import.meta.url, url: 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.

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 Init phase. Declaring the bucket next to the code that uses it keeps everything about the function in one file. Add it to the outer init:

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/unstable/http/HttpServerResponse";
export default class Api extends AWS.Lambda.Function<Api>()(
"Api",
{ main: import.meta.url, url: 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.

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 init, alongside the bucket:

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 for the exact mapping.

Replace the static handler with one that takes the URL path as the object key and writes the request body to the bucket:

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/unstable/http/HttpServerRequest";
import * as HttpServerResponse from "effect/unstable/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.

Add a GET branch that fetches the object and pipes it back to the client:

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<Uint8Array>, 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.

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:

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.

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:

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).

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:

src/api.ts
import * as Layer from "effect/Layer";
// ...
export default class Api extends AWS.Lambda.Function<Api>()(
"Api",
{ main: import.meta.url, url: 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.

The Api class is just a typed identifier — yielding it inside the Stack’s Effect is what registers the resource and starts the deploy:

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 url: true. We surface functionUrl as the Stack’s url output.

The Bucket is now declared inside Api’s init, so the copy in alchemy.run.ts is redundant — remove it:

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 Init phase runs — it just isn’t surfaced as a stack output anymore.

Deploy again. Alchemy detects the new function and the unchanged bucket:

Terminal window
bun alchemy deploy
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.

Use curl to write and read an object:

Terminal window
# 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!

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, you’ll write integration tests that deploy the stack and drive it over HTTP.