alepha@docs:~/framework/docs/guides/server$
cat 2-middlewares.md | pretty
12 min read
Last commit:

#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:

typescript
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:

typescript
 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:

typescript
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:

typescript
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:

typescript
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:

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

json
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".

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

typescript
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:

typescript
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:

typescript
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:

typescript
1Alepha.create({ "alepha.server.version.options": { path: "/_version" } });

#Metrics

AlephaServerMetrics exposes a Prometheus-compatible metrics endpoint. Import from alepha/server/metrics.

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

typescript
 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:

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

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

typescript
 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:

Flowchart diagramyesnoRequestserver:request:before hooksbody, multipart, cookies, CORS,authHook already answered?RValidate params, query, body$middleware for the pathAction use, first to lastHandlerSerialize response, validate JSONResponse

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:

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