#Middlewares
Alepha provides built-in middleware modules for common server needs.
#Built-in (AlephaServer)
The following are built into AlephaServer and active by default. Configure via atoms or disable globally.
#Compression
Response compression (gzip, brotli, zstd) based on the client's Accept-Encoding header. Active by default for JSON, HTML, JavaScript, CSS, and plain text responses.
The encoding with the highest q wins, and a tie goes to zstd, then br, then gzip (zstd only on a runtime that has it). A response that already carries Content-Encoding is left alone, and nothing is compressed on a serverless runtime, whose edge compresses for you.
Configure via the compressOptions atom:
1import { compressOptions } from "alepha/server";2 3alepha.store.mut(compressOptions, (old) => ({4 ...old,5 disabled: true, // disable compression entirely6}));
| Option | Default | Description |
|---|---|---|
disabled |
false |
Disable compression entirely |
allowedContentTypes |
["application/json", "text/html", "application/javascript", "text/plain", "text/css"] |
Content types eligible for compression |
#Security Headers (Helmet)
HTTP security headers on every response. Active by default.
Configure via the helmetOptions atom:
1import { helmetOptions } from "alepha/server"; 2 3alepha.store.mut(helmetOptions, (old) => ({ 4 ...old, 5 xFrameOptions: "DENY", 6 contentSecurityPolicy: { 7 directives: { 8 defaultSrc: ["'self'"], 9 scriptSrc: ["'self'", "https://cdn.example.com"],10 },11 },12}));
| Option | Default | Description |
|---|---|---|
disabled |
false |
Disable security headers entirely |
isSecure |
- | Force secure context (HSTS) |
strictTransportSecurity |
{ maxAge: 15552000, includeSubDomains: true } |
HSTS configuration |
xContentTypeOptions |
true (nosniff) |
X-Content-Type-Options header |
xFrameOptions |
"SAMEORIGIN" |
X-Frame-Options header |
xXssProtection |
false |
X-XSS-Protection header |
referrerPolicy |
"strict-origin-when-cross-origin" |
Referrer-Policy header |
contentSecurityPolicy |
- | CSP directives |
A header the handler already set is never overwritten. Strict-Transport-Security goes out only on a secure request (https, or X-Forwarded-Proto: https from your proxy), in production, or when isSecure is true.
contentSecurityPolicy has three states. Omit it and no CSP header is sent. Pass {} (no directives) to send the built-in policy: 'self' for scripts, forms, frames and images, 'none' for objects, plus upgrade-insecure-requests. Pass { directives: {} } to send no CSP header while leaving every other security header in place - an empty directive map is not a permissive policy, so it is dropped rather than sent empty.
#Body Parser
JSON, plain text and URL-encoded bodies are parsed for any route with a body schema, and a body over the limit answers 413 before the handler runs. Multipart has its own limits, below.
Configure via the bodyParserOptions atom:
1import { bodyParserOptions } from "alepha/server";2 3alepha.store.mut(bodyParserOptions, (old) => ({4 ...old,5 limit: 1_000_000, // 1MB6}));
| Option | Default | Description |
|---|---|---|
limit |
100000 (100KB) |
Maximum request body size in bytes, and ten times that once decompressed |
inflate |
true |
Decompress gzip, deflate and br bodies; off, a Content-Encoding answers 415 |
#Multipart
Multipart form-data parsing for file uploads. Runs for any route with a body schema when the request's content type is multipart/form-data, and handles z.file() and z.stream() parts.
Configure via the multipartOptions atom:
1import { multipartOptions } from "alepha/server";2 3alepha.store.mut(multipartOptions, (old) => ({4 ...old,5 limit: 50_000_000, // 50MB total6 fileLimit: 10_000_000, // 10MB per file7 fileCount: 20,8}));
| Option | Default | Description |
|---|---|---|
limit |
10000000 (10MB) |
Maximum total multipart request size in bytes |
fileLimit |
5000000 (5MB) |
Maximum single file size in bytes |
fileCount |
10 |
Maximum number of parts per request, text fields included |
#Optional Modules
Each section says what it takes: some are registered with alepha.with(), the others come with AlephaServer or with their primitive.
#CORS
Cross-Origin Resource Sharing comes from the AlephaServerCors module. Register it and configure the corsOptions atom for global behavior:
1import { Alepha } from "alepha";2import { AlephaServerCors, corsOptions } from "alepha/server/cors";3 4const alepha = Alepha.create().with(AlephaServerCors);5alepha.store.mut(corsOptions, (o) => ({6 ...o,7 origin: "https://app.example.com",8 credentials: true,9}));
For per-action CORS, attach the $cors middleware instead: use: [$cors({ origin: "..." })].
| Option | Default | Description |
|---|---|---|
origin |
"*" |
Allowed origins. "*" for all, or comma-separated list. |
methods |
["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] |
Allowed HTTP methods |
headers |
["Content-Type", "Authorization"] |
Allowed request headers |
credentials |
false |
Allow credentials (cookies, auth headers) |
maxAge |
- | Preflight cache duration in seconds |
Alepha automatically creates an OPTIONS preflight route for every path when the CORS module is active - including GET-only paths, which browsers preflight as soon as the request carries a non-simple header such as Authorization.
Responses always carry Vary: Origin, since the allowed origin is reflected from the request.
A Capacitor app calls the API from its WebView's origin. CAPACITOR_ORIGINS (from alepha/server/cors) is the two of them, capacitor://localhost (iOS) and https://localhost (Android), in the comma-separated form origin takes: origin: CAPACITOR_ORIGINS.
credentials requires an explicit origin. With origin: "*" the allowed origin is reflected back, so pairing it with credentials: true would let any site read authenticated responses - the exact thing the browser's own ban on Access-Control-Allow-Origin: * plus credentials prevents. Alepha refuses that combination: Access-Control-Allow-Credentials is omitted and a warning is logged at startup. List the origins you trust to enable credentials.
#Rate Limiting
Rate limiting is not part of AlephaServer: it comes from alepha/rate-limit, whose
limiters work on any call, HTTP or not. Declare a $rateLimit as a class field above
the actions that use it, and put its middleware() in their use:
1import { $rateLimit } from "alepha/rate-limit"; 2import { $action } from "alepha/server"; 3 4class App { 5 loginLimit = $rateLimit({ name: "login", limit: 5, window: [1, "minutes"] }); 6 7 login = $action({ 8 method: "POST", 9 path: "/auth/login",10 use: [this.loginLimit.middleware({ skipSuccessfulRequests: true })],11 handler: async () => "ok",12 });13}
Each call counts one hit against the key: key(...args) when given, else
ip:<request.ip> in an HTTP request, else ip:global (one bucket for a $job).
An allowed call sets X-RateLimit-Limit, X-RateLimit-Remaining and
X-RateLimit-Reset. Past the limit the middleware throws HttpError(429) with
Retry-After; if the counter store is down it throws HttpError(503) instead, since
an outage is not a limit the client exceeded.
| Middleware option | Default | Description |
|---|---|---|
key |
- | Key from the handler's arguments, instead of the request ip |
skipFailedRequests |
false |
Refund the hit when the handler fails |
skipSuccessfulRequests |
false |
Refund the hit when the handler succeeds (only failures count) |
The limit, the window, the failure modes and where counters live (one instance on
Memory, several once REDIS_URL is set, Durable Objects on workerd) belong to the limiter: see
Rate limiting.
#Health Check
GET /health and GET /healthz are part of AlephaServer - every server has them, with nothing to import.
1{ "message": "OK", "uptime": 42, "date": "2026-07-31T17:26:36Z", "ready": true }
ready is the field that matters. It follows the container's lifecycle, so it is false for exactly as long as the app is still starting - which is longer than you might expect, because a process binds its port before it runs its migrations.
That gap is why this is not opt-in. A supervisor or load balancer starting your app cannot ask it to expose a readiness endpoint; without one, the best it can do is open a TCP connection, which succeeds while the app is still building its database. It then sends traffic the app cannot serve. Alepha exposes /health universally so the caller can tell listening from working.
Put your reverse proxy in front of it: /health describes your internals and belongs on loopback, not on the public host. Bay returns 404 for it on the public interface.
AlephaServerHealth has been removed - delete the import if you still have one; /health ships with AlephaServer itself.
#Build Metadata
GET /version is part of AlephaServer too, and answers a different question from /health: not "can this take traffic" but "what is running here".
1{ 2 "name": "lore", 3 "version": "0.27.1", 4 "commit": "6faea71", 5 "build": { 6 "date": "2026-08-31T09:14:22.000Z", 7 "runtime": "workerd", 8 "dev": false 9 },10 "framework": "0.27.1"11}
The same record is readable anywhere in your app as alepha.meta, in the browser as well as on the server - it is resolved once at build time and baked into both bundles, so a footer showing the build date cannot disagree with what the server reports.
1alepha.meta.version; // "0.27.1", or "latest" on an untagged commit2alepha.meta.commit; // "6faea71", absent when the build had no git3alepha.meta.build.date; // ISO, absent when no build produced this code
version is the git tag on the built commit (a leading v is stripped), falling back to "latest". Two absences carry information rather than being holes: no commit means there was no git at all, and no build.date means nothing built this code - a test run, a script, or the package imported outside a build.
Declare a version yourself when the tag is not the answer you want. An app that deploys on every push resolves to "latest" on everything that is not a release:
1// alepha.config.ts2export default defineConfig({3 meta: { version: pkg.version },4});
⚠️ In CI, the default actions/checkout fetches shallow and passes --no-tags, so version reports "latest" unless the job that builds your deployed artifact sets fetch-tags: true. commit is unaffected - resolving it needs no tags - so even a "latest" build says exactly which commit is running.
Configure the route with versionOptions. expose trims the payload, most usefully to publish a version while withholding the commit; enabled: false makes the path answer 404 without touching /health:
1import { versionOptions } from "alepha/server";2 3alepha.set(versionOptions, { expose: ["name", "version"] });
path is the one option that must be seeded at construction rather than written afterwards, because routes are registered before a later write would land:
1Alepha.create({ "alepha.server.version.options": { path: "/_version" } });
#Metrics
AlephaServerMetrics exposes a Prometheus-compatible metrics endpoint. Import from alepha/server/metrics.
1import { Alepha } from "alepha";2import { AlephaServerMetrics } from "alepha/server/metrics";3 4Alepha.create().with(AlephaServerMetrics).with(App).start();
Serves metrics in Prometheus text format at /metrics.
Opt-in, unlike /health: it pulls in @prometheus-io/client, and an app that nothing scrapes should not carry it.
Set METRICS_TOKEN if the app itself is reachable from the network: the scraper then sends Authorization: Bearer <token>, and anything else answers 401. Alepha warns at startup when it is - production, no token, and SERVER_HOST bound to something other than loopback. An app on loopback behind a proxy gets no warning: the proxy decides what the internet sees.
#ETag and Conditional Responses
AlephaServerEtag adds the $etag middleware, which hashes a response, sends
it as an ETag, and answers 304 Not Modified when the client sends the same
value back in If-None-Match.
1import { $action } from "alepha/server"; 2import { $etag } from "alepha/server/etag"; 3 4class Articles { 5 get = $action({ 6 method: "GET", 7 path: "/articles/:id", 8 use: [$etag()], 9 handler: async () => "an article",10 });11}
Bare $etag() only validates: the handler still runs on every request, and the
saving is bandwidth, not work. Pass true to also store the response, which
short-circuits the handler on a hit:
| Written as | Handler runs on a hit | Response body sent |
|---|---|---|
$etag() |
yes | no (304) |
$etag(true) |
no | no (304) |
$etag({ store: [5, "minutes"] }) |
no | no (304) |
control sets Cache-Control on top of that, either as a literal string or as
directives:
1import { $action } from "alepha/server"; 2import { $etag } from "alepha/server/etag"; 3 4class Stats { 5 get = $action({ 6 method: "GET", 7 path: "/stats", 8 use: [ 9 $etag({10 store: { ttl: [5, "minutes"] },11 control: { public: true, maxAge: 300 },12 }),13 ],14 handler: async () => "stats",15 });16}
Stored responses are namespaced by caller identity, taken from the
authorization and cookie headers, so an authenticated route cannot serve one
user's body to another. Anonymous callers share a single entry. That is the
right default and it is also worth knowing before you put store on a route
whose response varies by something other than those two headers, such as an
Accept-Language or a tenant header: those responses would share an entry.
#Path-Scoped Middleware
Everything above is either global (a module) or per-action (use: [...]).
$middleware is the level in between: middleware applied to every route under a
path prefix.
1import { $secure } from "alepha/security";2import { $middleware } from "alepha/server";3 4class Gateway {5 api = $middleware({6 path: "/api",7 use: [$secure()],8 });9}
| Option | Effect |
|---|---|
path |
Prefix to match. /api covers /api/users, /api/orders, not /api itself |
use |
The middleware to apply |
method |
Restrict to one HTTP method, or a list |
exclude |
Route paths to leave alone, each with everything under it |
It covers every route under the prefix, $route and $page included, whether it was declared before or after the $middleware. Its middleware runs outside the action's own use, so a $secure() here has already refused the caller before any per-action middleware sees the call. It joins the action's own pipeline, so it also runs when the action is called in process, through run() or a local $client. OpenAPI reads the action's own use alone, so a gate declared here is enforced but not documented.
Use it for a cross-cutting concern that belongs to a whole path family, and keep
per-action use for behaviour that belongs to one action. The test is whether
you would have to remember to add it: a header every /api route must carry is a
$middleware, while a retry policy that suits one flaky upstream call is not.
exclude exists for the route inside the family that must not have it, such as a
health probe under a prefix that otherwise requires a token.
#Writing Your Own
An action middleware is an ordinary pipeline middleware: createMiddleware from alepha, whose wrapped function receives the action's ServerRequest as its first argument. On a local run() that request is a partial one: requestId, userAgent and the other fields the HTTP layer fills are undefined, signal is the caller's, and what a middleware sets on reply is thrown away.
1import { createMiddleware, type Middleware } from "alepha"; 2import { $action, type ServerRequest } from "alepha/server"; 3 4const $poweredBy = (value: string): Middleware => 5 createMiddleware({ 6 name: "$poweredBy", 7 options: { value }, 8 handler: ({ next }) => { 9 return async (...args) => {10 const request = args[0] as ServerRequest;11 request.reply.setHeader("X-Powered-By", value);12 return next(...args);13 };14 },15 });16 17class Hello {18 hello = $action({19 method: "GET",20 path: "/hello",21 use: [$poweredBy("alepha")],22 handler: async () => "hello",23 });24}
The first middleware in use is the outermost. To refuse a call, throw an HttpError (or one of its subclasses) instead of calling next. Two meta keys describe what a middleware does to tooling that does not know it by name: MIDDLEWARE_RESPONSES ("401,403", a comma-separated list) adds those statuses to the OpenAPI document of an action that declares errors, and MIDDLEWARE_PROTECTED ("true") marks it as gated behind an identity, which documents a bearerAuth requirement. Both are exported by alepha.
#Where Each Layer Runs
A request crosses the global modules as event hooks around the route, and the middlewares as wrappers around the handler:
A throw anywhere between validation and serialization goes to the error handler and then on to the response hooks. An action's timeout covers its middlewares and its handler, not the validation before them.
#Combining Middlewares
Register multiple modules together:
1import { Alepha } from "alepha"; 2import { AlephaRateLimit } from "alepha/rate-limit"; 3import { AlephaServerCors, corsOptions } from "alepha/server/cors"; 4 5const alepha = Alepha.create().with(AlephaServerCors).with(AlephaRateLimit); 6 7alepha.store.mut(corsOptions, (o) => ({ 8 ...o, 9 origin: "https://app.example.com",10}));11 12await alepha.start();
Module order in .with() calls does not affect execution order. Alepha uses hook priorities internally to ensure correct ordering (e.g., CORS headers are set before the handler runs).