#Building an API
Alepha provides type-safe HTTP endpoints through the $action primitive. Actions are class properties that define request schemas, response schemas, and handler logic in a single declaration.
#Quick Start
Scaffold a new project:
alepha init my-api
Every Alepha project ships with src/api/ - a server entry point, a sample controller, and Zod schemas. Building an API-only service? Delete src/web/ and the WebModule line from main.server.ts.
#Defining Actions
Actions are defined as class properties using $action. Each action becomes an HTTP endpoint.
1import { z } from "alepha"; 2import { $action } from "alepha/server"; 3 4class ProductController { 5 list = $action({ 6 method: "GET", 7 path: "/products", 8 schema: { 9 query: z.object({10 page: z.integer().default(1),11 limit: z.integer().default(10),12 }),13 // good practice is to move complex schemas to separate files (api/schemas/*) and import them14 response: z.array(15 z.object({16 id: z.uuid(),17 name: z.text(),18 price: z.number(),19 }),20 ),21 },22 handler: async ({ query }) => {23 return await this.repo.findMany({24 limit: query.limit,25 offset: (query.page - 1) * query.limit,26 });27 },28 });29 30 create = $action({31 method: "POST",32 path: "/products",33 schema: {34 body: z.object({35 name: z.text(),36 price: z.number(),37 }),38 response: z.object({ id: z.uuid(), name: z.text(), price: z.number() }),39 },40 handler: async ({ body }) => {41 return await this.repo.create(body);42 },43 });44}
#URL Generation
$action sits above $route: same pipeline, but all paths are prefixed with /api by default.
1$action({ method: "GET", path: "/users" }); // GET /api/users2$action({ method: "GET", path: "/users/:id" }); // GET /api/users/:id
The prefix is configurable via the serverApiOptions atom:
1import { serverApiOptions } from "alepha/server";2 3alepha.store.mut(serverApiOptions, (o) => ({ ...o, prefix: "/v1" }));4// now: GET /v1/users
path is required. It never derives from the property name, so renaming getUser to findUser cannot move a public URL. Every :name in it is declared in schema.params:
1class App { 2 getUser = $action({ 3 method: "GET", 4 path: "/users/:id", 5 schema: { params: z.object({ id: z.uuid() }) }, 6 handler: async ({ params }) => { 7 /* ... */ 8 }, 9 });10 // GET /api/users/:id11}
#HTTP Method
method is required, with or without a body. A read declares method: "GET": it is a claim that the call changes nothing, which is what the HTTP client's ETag cache and request deduplication rely on, and what keeps a state-changing endpoint out of reach of a cross-site link (browsers send the SameSite=Lax session cookie on a top-level GET). Any other method is set the same way:
1update = $action({ 2 method: "PUT", 3 path: "/products/:id", 4 schema: { 5 params: z.object({ id: z.uuid() }), 6 body: z.object({ name: z.text(), price: z.number() }), 7 response: z.object({ id: z.uuid(), name: z.text(), price: z.number() }), 8 }, 9 handler: async ({ params, body }) => {10 return await this.repo.update(params.id, body);11 },12});
Supported methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, CONNECT, TRACE.
HEAD is answered for free by every GET route. A request the router
cannot match under HEAD is retried under GET, and the response is sent
without its body, keeping the status and the headers a GET would have sent -
including content-length, which describes the body that was not sent. That is
what an uptime monitor, a load balancer health check or a link checker expects,
and most of them use HEAD. Declaring a HEAD route explicitly still wins over
the fallback, and the fallback only ever reaches GET: a POST-only path stays
unreachable by HEAD.
#Schema Object
The schema option accepts up to five fields:
| Field | Purpose |
|---|---|
params |
Path parameters (e.g. /products/:id) |
query |
URL query parameters |
body |
Request body (JSON, text, or multipart) |
headers |
Required request headers |
response |
Response body shape |
All fields use Zod schemas via the z helper from alepha. The handler receives fully validated and typed request data: an invalid request is answered 400 before the handler runs.
The response schema works the other way. What the handler returns is encoded through it, so a property the schema does not declare is stripped rather than leaked. That holds for an object, record or array schema: a text, number or boolean response is not validated, and the value goes out as a string. Without a response, the handler's type allows a string, a Buffer, a stream or nothing, but not an object: add a schema to return JSON. An object that gets past the type still goes out through JSON.stringify, with nothing stripped.
#The Request
Besides the validated params, query, body and headers, the handler's argument carries what the server knows about the call:
| Field | What it holds |
|---|---|
url, method, requestId |
The request URL, its method and its id (from X-Request-Id or a new one) |
ip, host, protocol |
The client address, the host it called and "http" or "https" |
userAgent, isBot, isMobile |
The parsed User-Agent |
geo, language, referer |
Location hints, the preferred language and the parsed Referer |
signal |
An AbortSignal that fires when the client disconnects |
reply |
The response being built: reply.setHeader(), reply.status |
metadata |
A free-form record middlewares use to pass data along |
Middlewares in use can add fields: $secure() adds a user the handler can read without checking it for undefined.
ip trusts proxy headers by default. With TRUST_PROXY=true it reads cf-connecting-ip, then the X-Forwarded-For entry TRUST_PROXY_HOPS (default 1) places from the right, then X-Real-IP, and only then the socket. A server that takes direct connections must set TRUST_PROXY=false, or a client chooses its own address, and with it its rate-limit key.
#Action Options
Beyond method, path, schema and handler:
| Option | Effect |
|---|---|
use |
Middlewares around the handler, outermost first ($secure(), $rateLimit, $etag()) |
errors |
The errors the action answers with; anything else becomes a 500. See Typed errors |
timeout |
How long the action may run ([10, "seconds"]) before it answers 503 with a TimeoutError; false opts out of the global one |
group |
See Groups |
description |
A short description for the OpenAPI document |
name |
The action's name, which defaults to the property name and feeds its permission name |
silent |
Keep the action's requests out of the request log |
disabled |
See Disabling an Action |
No action has a timeout by default; the serverApiOptions atom's timeout (in milliseconds) sets one for all of them, and an action's own timeout wins. The deadline covers the use middlewares and the handler, and aborts the request's signal, the same object as alepha.context.signal, so that queries and outbound calls, which read it, stop too. Hand it to anything that takes a signal explicitly, like a bare fetch.
#Groups
Actions in the same class share a group. The group defaults to the class name. Groups are used for OpenAPI tags and permission namespacing.
Override the group explicitly:
1class AdminController { 2 group = "admin"; 3 4 listUsers = $action({ 5 method: "GET", 6 path: "/listUsers", 7 group: this.group, 8 handler: () => { 9 /* ... */10 },11 });12 13 deleteUser = $action({14 method: "POST",15 path: "/deleteUser",16 group: this.group,17 handler: () => {18 /* ... */19 },20 });21}
#Disabling an Action
The disabled option prevents the route from being registered. Useful for feature flags:
1import { $env, z } from "alepha"; 2import { $action } from "alepha/server"; 3 4class App { 5 env = $env( 6 z.object({ 7 ENABLE_BETA: z.boolean().default(false), 8 }), 9 );10 11 beta = $action({12 method: "POST",13 path: "/beta",14 disabled: !this.env.ENABLE_BETA,15 handler: () => "beta feature",16 });17}
A disabled action is not available locally either: calling it with .run() throws an AlephaError.
#Calling Actions Programmatically
Actions can be called directly (no HTTP overhead) or via HTTP:
1// Direct local call - runs the handler in-process2const result = await this.list.run({ query: { page: 1, limit: 10 } });3 4// Force HTTP call - sends an actual HTTP request to the server5const response = await this.list.fetch({ query: { page: 1, limit: 10 } });6 7// Calling the action itself is the same as .run() - always local, never HTTP8const same = await this.list({ query: { page: 1, limit: 10 } });
For local-first-then-HTTP dispatch, use
$clientlinks, which work across process and network boundaries (see HTTP Links). Calling controllers directly is not recommended for shared libraries.
#Streaming with SSE
For endpoints that stream data progressively (AI chat, progress updates, live feeds), use $sse instead of $action. It returns a text/event-stream response that the client consumes as an async iterable.
1import { z } from "alepha"; 2import { $sse } from "alepha/server"; 3 4class AiController { 5 chat = $sse({ 6 path: "/chat", 7 schema: { 8 body: z.object({ prompt: z.text() }), 9 data: z.object({ token: z.text() }),10 },11 handler: async ({ body, emit }) => {12 for await (const token of generateTokens(body.prompt)) {13 emit({ token });14 }15 // stream auto-closes when handler returns16 },17 });18}
The handler receives emit() to push typed events and close() to end the stream early. The stream closes automatically when the handler returns. emit() returns false once the stream is over (the client left or close() was called) and the event was dropped. It also receives signal, an AbortSignal that fires when the client disconnects - check it in any long-running loop, or the handler keeps running for a reader that is gone.
On the client, SSE endpoints are consumed through the same $client proxy as actions:
1const ctrl = $client<AiController>();2const stream = await ctrl.chat({ body: { prompt: "hello" } });3 4for await (const chunk of stream) {5 console.log(chunk.token);6}
Key differences from $action:
- Method is always POST
usemiddlewares are accepted as on$action($secure()refuses before the stream opens)- Response is
text/event-stream(not JSON) - Schema uses
data(event shape) instead ofresponse - Client receives an async iterable instead of a single value