# 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
import { compressOptions } from "alepha/server";

alepha.store.mut(compressOptions, (old) => ({
  ...old,
  disabled: true, // disable compression entirely
}));
```

| 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
import { helmetOptions } from "alepha/server";

alepha.store.mut(helmetOptions, (old) => ({
  ...old,
  xFrameOptions: "DENY",
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "https://cdn.example.com"],
    },
  },
}));
```

| 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
import { bodyParserOptions } from "alepha/server";

alepha.store.mut(bodyParserOptions, (old) => ({
  ...old,
  limit: 1_000_000, // 1MB
}));
```

| 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
import { multipartOptions } from "alepha/server";

alepha.store.mut(multipartOptions, (old) => ({
  ...old,
  limit: 50_000_000, // 50MB total
  fileLimit: 10_000_000, // 10MB per file
  fileCount: 20,
}));
```

| 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
import { Alepha } from "alepha";
import { AlephaServerCors, corsOptions } from "alepha/server/cors";

const alepha = Alepha.create().with(AlephaServerCors);
alepha.store.mut(corsOptions, (o) => ({
  ...o,
  origin: "https://app.example.com",
  credentials: true,
}));
```

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
import { $rateLimit } from "alepha/rate-limit";
import { $action } from "alepha/server";

class App {
  loginLimit = $rateLimit({ name: "login", limit: 5, window: [1, "minutes"] });

  login = $action({
    method: "POST",
    path: "/auth/login",
    use: [this.loginLimit.middleware({ skipSuccessfulRequests: true })],
    handler: async () => "ok",
  });
}
```

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](https://latest.alepha.dev/docs/guides-server-rate-limiting.md).

### Health Check

`GET /health` and `GET /healthz` are part of `AlephaServer` - every server has them, with nothing to import.

```json
{ "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](https://latest.alepha.dev/bay) returns 404 for it on the public interface.

<!-- docs-check-ignore: migration note about a removed symbol -->

`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
{
  "name": "lore",
  "version": "0.27.1",
  "commit": "6faea71",
  "build": {
    "date": "2026-08-31T09:14:22.000Z",
    "runtime": "workerd",
    "dev": false
  },
  "framework": "0.27.1"
}
```

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
alepha.meta.version; // "0.27.1", or "latest" on an untagged commit
alepha.meta.commit; // "6faea71", absent when the build had no git
alepha.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
// alepha.config.ts
export default defineConfig({
  meta: { version: pkg.version },
});
```

⚠️ 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
import { versionOptions } from "alepha/server";

alepha.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
Alepha.create({ "alepha.server.version.options": { path: "/_version" } });
```

### Metrics

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

```typescript
import { Alepha } from "alepha";
import { AlephaServerMetrics } from "alepha/server/metrics";

Alepha.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
import { $action } from "alepha/server";
import { $etag } from "alepha/server/etag";

class Articles {
  get = $action({
    method: "GET",
    path: "/articles/:id",
    use: [$etag()],
    handler: async () => "an article",
  });
}
```

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
import { $action } from "alepha/server";
import { $etag } from "alepha/server/etag";

class Stats {
  get = $action({
    method: "GET",
    path: "/stats",
    use: [
      $etag({
        store: { ttl: [5, "minutes"] },
        control: { public: true, maxAge: 300 },
      }),
    ],
    handler: async () => "stats",
  });
}
```

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
import { $secure } from "alepha/security";
import { $middleware } from "alepha/server";

class Gateway {
  api = $middleware({
    path: "/api",
    use: [$secure()],
  });
}
```

| 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](https://latest.alepha.dev/docs/guides-server-openapi.md) 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](https://latest.alepha.dev/docs/guides-core-pipelines.md#writing-a-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
import { createMiddleware, type Middleware } from "alepha";
import { $action, type ServerRequest } from "alepha/server";

const $poweredBy = (value: string): Middleware =>
  createMiddleware({
    name: "$poweredBy",
    options: { value },
    handler: ({ next }) => {
      return async (...args) => {
        const request = args[0] as ServerRequest;
        request.reply.setHeader("X-Powered-By", value);
        return next(...args);
      };
    },
  });

class Hello {
  hello = $action({
    method: "GET",
    path: "/hello",
    use: [$poweredBy("alepha")],
    handler: async () => "hello",
  });
}
```

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:

```mermaid
flowchart TD
  A[Request] --> B["server:request:before hooks<br/>body, multipart, cookies, CORS, auth"]
  B --> C{Hook already answered?}
  C -->|yes| R
  C -->|no| D[Validate params, query, body]
  D --> E[$middleware for the path]
  E --> F[Action use, first to last]
  F --> G[Handler]
  G --> H["Serialize response, validate JSON"]
  H --> R["server:response hooks<br/>etag, helmet, compression"]
  R --> S[Response]
```

A throw anywhere between validation and serialization goes to the [error handler](https://latest.alepha.dev/docs/guides-server-error-handling.md) 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
import { Alepha } from "alepha";
import { AlephaRateLimit } from "alepha/rate-limit";
import { AlephaServerCors, corsOptions } from "alepha/server/cors";

const alepha = Alepha.create().with(AlephaServerCors).with(AlephaRateLimit);

alepha.store.mut(corsOptions, (o) => ({
  ...o,
  origin: "https://app.example.com",
}));

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