# Middleware

> Plugins and middleware allow adding reusable server extensions.

## Example

```ts
import { serve, type ServerMiddleware, type ServerPlugin } from "srvx";

const xPoweredBy: ServerMiddleware = async (req, next) => {
  const res = await next();
  res.headers.set("X-Powered-By", "srvx");
  return res;
};

const devLogs: ServerPlugin = (server) => {
  if (process.env.NODE_ENV === "production") {
    return;
  }
  console.log(`Logger plugin enabled!`);
  server.options.middleware.push((req, next) => {
    console.log(`[request] [${req.method}] ${req.url}`);
    return next();
  });
};

serve({
  middleware: [xPoweredBy],
  plugins: [devLogs],
  fetch(request) {
    return new Response(`👋 Hello there.`);
  },
});
```

## Order of execution

Middleware run in the order they appear in the `middleware` array, each wrapping the next, with your `fetch` handler at the center. A middleware that returns a response **without** calling `next()` short-circuits the rest of the chain — nothing after it runs.

Plugins are applied in `plugins` array order, before the server starts listening. A plugin that pushes middleware appends to the end of the array, so `middleware` entries always run first.

## Built-in middleware and plugins

srvx ships several optional extensions as separate subpath imports. All of them are opt-in — importing `srvx` alone pulls in none of them.

<table>
<thead>
  <tr>
    <th>
      Import
    </th>
    
    <th>
      Export
    </th>
    
    <th>
      Kind
    </th>
    
    <th>
      Runtimes
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        srvx/log
      </code>
    </td>
    
    <td>
      <code>
        loggerMiddleware()
      </code>
    </td>
    
    <td>
      Middleware
    </td>
    
    <td>
      All
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        srvx/static
      </code>
    </td>
    
    <td>
      <code>
        staticMiddleware()
      </code>
    </td>
    
    <td>
      Middleware
    </td>
    
    <td>
      Node, Deno, Bun
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        srvx/mtls
      </code>
    </td>
    
    <td>
      <code>
        mtlsPlugin()
      </code>
    </td>
    
    <td>
      Plugin
    </td>
    
    <td>
      Node
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        srvx/tracing
      </code>
    </td>
    
    <td>
      <code>
        tracingPlugin()
      </code>
    </td>
    
    <td>
      Plugin
    </td>
    
    <td>
      Node, Deno, Bun
    </td>
  </tr>
</tbody>
</table>

### Request logging

`loggerMiddleware()` from `srvx/log` prints one line per request with the method, URL, status, and duration.

Colors are disabled when `NODE_ENV` is `production`, as well as when [`NO_COLOR`](https://no-color.org/) is set or output is not a TTY. Set `FORCE_COLOR` to keep them in any of those cases.

```ts [server.ts]
import { serve } from "srvx";
import { loggerMiddleware } from "srvx/log";

serve({
  middleware: [loggerMiddleware()],
  fetch: () => new Response("👋 Hello there."),
});
```

```sh
[10:32:03 AM] GET http://localhost:3000/ [200] (1.42ms)
```

The duration is measured around `next()`, so place `loggerMiddleware()` first in the array for it to cover the whole chain. The [CLI](/guide/cli) enables this middleware automatically.

**loggerMiddleware() options:**

- `batch`: Coalesce lines and write once per event-loop turn (default: `true`). Set to `false` to hand each line to stdout as its request completes — slower under concurrency, but a line never waits for a flush turn.

```ts
serve({
  middleware: [loggerMiddleware({ batch: false })],
  fetch: () => new Response("👋 Hello there."),
});
```

**Crash safety:** buffered lines are flushed when the process ends — on normal exit, `process.exit()`, uncaught exceptions, and `SIGHUP`/`SIGTERM`/`SIGINT`, whether a shutdown handler (such as srvx's graceful shutdown) is installed or the signal is default-handled. Only a hard kill (`SIGKILL`, the OOM killer) can drop the lines of the final event-loop turn; `batch: false` narrows that window to the single write in flight.

### Static files

`staticMiddleware()` from `srvx/static` serves files from a directory, with `index.html` resolution, `.html` extension fallback (`/about` → `about.html`), common MIME types, gzip/Brotli compression, and path-traversal protection.

```ts [server.ts]
import { serve } from "srvx";
import { staticMiddleware } from "srvx/static";

serve({
  middleware: [staticMiddleware({ dir: "public" })],
  fetch: () => new Response("Not found", { status: 404 }),
});
```

When no file matches the request, it calls `next()` — so your handler acts as the fallback for unmatched paths.

**staticMiddleware() options:**

- `dir`: The directory to serve files from (required).
- `methods`: HTTP methods to serve (default `["GET", "HEAD"]`). Other methods fall through to `next()`.
- `dotfiles`: Dot segments (path segments starting with `.`) that may be served (default `[".well-known"]`). A path containing any other dot segment — `/.env`, `/.git/config` — falls through to `next()`. Pass `true` to serve every dot segment, or `false` (or `[]`) to serve none, including `/.well-known/`.
- `encodings`: Serve precompressed variants from disk (default `false`). Pass `true` for `{ br: ".br", gzip: ".gz" }`, or a map setting the extension per encoding (keys tried in order, preferred first). Off by default because most deployments ship no precompressed files, so the lookup is a `stat` that always misses.
- `compress`: Compress a response on the fly when no precompressed variant is served (default `true`). Pass `false` to serve only what is already on disk.
- `lastModified`: Emit a `Last-Modified` header from the file's modification time, and answer a matching `If-Modified-Since` request with `304 Not Modified` (default `true`).
- `etag`: Emit a weak `ETag` validator, and answer a matching `If-None-Match` request with `304 Not Modified` (default `true`).
- `maxAge`: Freshness lifetime in **seconds**, emitted as `Cache-Control: max-age=<n>` (default `undefined`, no header). Lets a client reuse a response without a request until it goes stale.
- `immutable`: Add the `immutable` directive to `Cache-Control`, so a client does not revalidate a still-fresh response even on reload (default `false`). Only takes effect alongside `maxAge`, and only makes sense for fingerprinted (content-hashed) assets.
- `renderHTML`: A function receiving `{ request, html, filename }` for every HTML file (`.html`, `.htm`), returning the `Response` to send. Use it to inject or template markup before serving.

A request resolves in order: the path itself, then `<path>.html`, then `<path>/index.html`. So `/about` serves `about.html`, while an extension-less file (`LICENSE`, an ACME challenge token) is served at its exact name. A trailing-slash request names a directory, so `/sub/` resolves only `sub/index.html` — never `sub.html` or a file named `sub`.

By default a compressible response is compressed on the fly as it is sent. Enabling `encodings` adds a disk lookup that takes precedence: for `/app.js` with `Accept-Encoding: br`, `app.js.br` is served if it exists (with `Content-Encoding: br`), and only a missing variant falls back to on-the-fly. A variant always wins because it costs no CPU, and a build can afford a better ratio than a per-request encode can justify — so `encodings: true` plus a build step is the cheapest way to serve maximum-quality compressed assets. The two switches are independent: `compress: false` serves only what is on disk, and `encodings` off with `compress` on always compresses on the fly.

Brotli compresses at quality 4 rather than the `node:zlib` default of 11, which costs roughly 12x the CPU for a few percent of size. Only files between 1 KiB and 10 MiB are compressed on the fly: below that the encoded body can come out larger than the input, and above it the CPU spent per request outweighs the bandwidth saved — precompress large assets instead. On-the-fly responses are chunked, since the encoded length is not known until the bytes exist, while a variant declares the `Content-Length` it has on disk.

Compression applies to compressible types only, so a `.br` next to an image or font is ignored, and those responses omit `Vary: Accept-Encoding` — which compressible ones always set, including uncompressed ones, as a shared cache must key on the header either way. `renderHTML` routes are never compressed and always read the source file: a variant on disk would not match the rendered output, and the `Response` the hook returns is the caller's to encode.

Every file served without `renderHTML` carries an `ETag` and a `Last-Modified` header, and a conditional request that still matches is answered with an empty `304 Not Modified` before the body is ever read. `If-None-Match` takes precedence over `If-Modified-Since`, matching [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#section-13.2.2). The `ETag` is weak (`W/"…"`): it is derived from the file's size and modification time rather than its bytes, and folds in the `Content-Encoding` so a brotli and a gzip response under one URL get distinct validators. Pass `etag: false` or `lastModified: false` to drop either header and stop honoring its conditional. `renderHTML` routes carry neither, since the rendered body is the caller's to validate.

`Cache-Control` is opt-in and off by default, so a client revalidates with those validators on every use. Set `maxAge` (in seconds) to send `Cache-Control: max-age=<n>` and let a client reuse a response without a request until it goes stale; add `immutable: true` to send `max-age=<n>, immutable`, which also skips revalidation on an explicit reload — appropriate for a fingerprinted asset whose URL changes when its bytes do. The header rides along on the `304` too, so a revalidation refreshes the stored freshness. Like the validators, it is omitted on `renderHTML` routes.

`/.well-known/` is served by default because [RFC 8615](https://www.rfc-editor.org/rfc/rfc8615) reserves it for public metadata: ACME HTTP-01 challenges and `security.txt` live there. Allow-listing is by exact segment name, so `[".well-known"]` serves neither a sibling sharing its prefix (`.well-known-backup`) nor a dot segment nested under it (`.well-known/.env`).

Text responses declare `charset=utf-8`; without it a browser decodes them with a fallback of its own choosing, mangling any non-ASCII byte the file does not declare inline.

<note>

Files are only served from within `dir`, and both rules above are re-checked against the path a symlink actually resolves to. Symlinks are followed, but one resolving outside `dir` — or onto a dot segment `dotfiles` hides, such as `public.txt` → `.env` — falls through to `next()` instead of being served.

</note>

<note>

Despite the runtime-neutral name, `srvx/static` is **Node-API-only** — it uses `node:fs` internally, so it works only on runtimes with Node.js compatibility (Node, Deno, Bun).

</note>

See [Serving static files](/guide/cli#serving-static-files) for the equivalent CLI flag.

### Mutual TLS

[`mtlsPlugin()`](/guide/tls#mutual-tls-mtls) from `srvx/mtls` requests a client certificate during the TLS handshake and exposes it on [`request.tls`](/guide/handler#requesttls). It requires the [Node.js adapter](/guide/node).

### Tracing

`tracingPlugin()` from `srvx/tracing` wraps your `fetch` handler and each middleware with [`diagnostics_channel`](https://nodejs.org/api/diagnostics_channel.html) instrumentation, publishing to the `srvx.request` and `srvx.middleware` [tracing channels](https://nodejs.org/api/diagnostics_channel.html#class-tracingchannel).

```ts [server.ts]
import { serve } from "srvx";
import { tracingPlugin } from "srvx/tracing";
import { tracingChannel } from "node:diagnostics_channel";

tracingChannel("srvx.request").subscribe({
  start: ({ request }) => console.log(`[start] ${request.url}`),
  asyncEnd: ({ request }) => console.log(`[end] ${request.url}`),
  error: ({ request, error }) => console.error(`[error] ${request.url}`, error),
});

serve({
  plugins: [tracingPlugin()],
  fetch: () => new Response("👋 Hello there."),
});
```

Each event carries `{ server, request }`, plus `{ middleware: { index, handler } }` on the `srvx.middleware` channel. Pass `{ fetch: false }` or `{ middleware: false }` to instrument only one of the two.

Because plugins run in order, `tracingPlugin()` only wraps middleware registered before it — keep it **last** in the `plugins` array so it covers middleware added by earlier plugins.

<important>

`srvx/tracing` is **experimental**.

</important>
