1import * as AWS from "alchemy/AWS";
2import * as Kubernetes from "alchemy/Kubernetes";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { HttpServerRequest } from "effect/http/HttpServerRequest";
6import * as HttpServerResponse from "effect/http/HttpServerResponse";
7import { EntriesTable, GuestbookCluster, GuestbookNamespace } from "./infra.ts";
8
9/**
10 * The guestbook API: a `Kubernetes.Deployment` in the TAGGED form — the class
11 * declares the deployment identity, and the default export is
12 * `Api.make(props, impl)`: a Layer pairing the props with an init Effect
13 * whose impl returns `{ fetch }`, bundled into a generated image
14 * (`main: import.meta.url`). The Deployment synthesizes the Kubernetes
15 * Deployment + Service + ServiceAccount (server-side apply), a pod-identity
16 * IAM role + PodIdentityAssociation, and an ECR repository.
17 *
18 * The stack provides the Layer and yields the class (see `alchemy.run.ts`):
19 *
20 * ```typescript
21 * const api = yield* Api; // with Effect.provide(ApiLive)
22 * ```
23 *
24 * `serviceType: "LoadBalancer"` has Auto Mode's built-in controller
25 * provision an internet-facing NLB; `api.url` is the full URL INCLUDING the
26 * Service port (`http://<nlb-hostname>:3000` — Kubernetes maps
27 * `spec.ports[].port` 1:1 to the cloud listener).
28 *
29 * Bindings work exactly as on Lambda and ECS:
30 * - `AWS.DynamoDB.PutItem` / `GetItem` / `Scan` land IAM on the generated
31 * pod-identity role and inject the table name into the pod. At runtime the
32 * pod resolves credentials through the EKS Pod Identity
33 * container-credentials chain — no static keys, no IRSA annotations.
34 *
35 * `podTemplate` is the Kubernetes escape hatch: a literal deep-partial Pod
36 * template merged into the synthesized one (objects merge recursively;
37 * arrays and primitives replace).
38 */
39export class Api extends Kubernetes.Deployment<Api>()("Api") {}
40
41export default Api.make(
42 // Props are themselves an Effect so they can reference shared resources.
43 Effect.gen(function* () {
44 const cluster = yield* GuestbookCluster;
45 const ns = yield* GuestbookNamespace;
46 return {
47 cluster,
48 main: import.meta.url,
49 // Reference the Manifest's attribute (not a bare string) so the
50 // Deployment depends on — and deploys after — the namespace.
51 namespace: ns.name,
52 port: 3000,
53 replicas: 2,
54 serviceType: "LoadBalancer" as const,
55 resources: {
56 requests: { cpu: "100m", memory: "128Mi" },
57 limits: { cpu: "500m", memory: "256Mi" },
58 },
59 // Kubernetes escape hatch: tune the synthesized Pod template with a
60 // literal deep-partial object merged onto it.
61 podTemplate: {
62 metadata: {
63 annotations: {
64 "prometheus.io/scrape": "true",
65 "prometheus.io/port": "3000",
66 },
67 },
68 spec: { terminationGracePeriodSeconds: 30 },
69 },
70 };
71 }),
72 Effect.gen(function* () {
73 const table = yield* EntriesTable;
74
75 const putItem = yield* AWS.DynamoDB.PutItem(table);
76 const getItem = yield* AWS.DynamoDB.GetItem(table);
77 const scan = yield* AWS.DynamoDB.Scan(table);
78
79 return {
80 fetch: Effect.gen(function* () {
81 const request = yield* HttpServerRequest;
82 const url = new URL(request.url, "http://guestbook");
83
84 // GET /entries — list everything in the table.
85 if (request.method === "GET" && url.pathname === "/entries") {
86 const result = yield* scan({});
87 const entries = (result.Items ?? []).map((item) => ({
88 id: item.pk?.S?.replace(/^entry#/, ""),
89 author: item.author?.S,
90 message: item.message?.S,
91 }));
92 return yield* HttpServerResponse.json({
93 count: entries.length,
94 entries,
95 });
96 }
97
98 // GET /entries/<id> — read one entry.
99 const match = url.pathname.match(/^\/entries\/([^/]+)$/);
100 if (request.method === "GET" && match) {
101 const result = yield* getItem({
102 Key: { pk: { S: `entry#${match[1]}` } },
103 });
104 if (!result.Item) {
105 return yield* HttpServerResponse.json(
106 { error: "not found" },
107 { status: 404 },
108 );
109 }
110 return yield* HttpServerResponse.json({
111 id: match[1],
112 author: result.Item.author?.S,
113 message: result.Item.message?.S,
114 });
115 }
116
117 // POST /entries?author=ada&message=hi — sign the guestbook.
118 if (request.method === "POST" && url.pathname === "/entries") {
119 const author = url.searchParams.get("author") ?? "anonymous";
120 const message = url.searchParams.get("message") ?? "";
121 const id = yield* Effect.sync(() => crypto.randomUUID().slice(0, 8));
122 yield* putItem({
123 Item: {
124 pk: { S: `entry#${id}` },
125 author: { S: author },
126 message: { S: message },
127 },
128 });
129 return yield* HttpServerResponse.json({ id, author, message });
130 }
131
132 // Everything else — including "/" health probes.
133 return yield* HttpServerResponse.json({
134 ok: true,
135 service: "guestbook-api",
136 });
137 }).pipe(Effect.orDie),
138 };
139 }).pipe(
140 Effect.provide(
141 Layer.mergeAll(
142 AWS.DynamoDB.PutItemHttp,
143 AWS.DynamoDB.GetItemHttp,
144 AWS.DynamoDB.ScanHttp,
145 ),
146 ),
147 ),
148);