Middleware

Plugins and middleware allow adding reusable server extensions.

Example

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.

ImportExportKindRuntimes
srvx/loglog()MiddlewareAll
srvx/staticserveStatic()MiddlewareNode, Deno, Bun
srvx/mtlsmtls()PluginNode
srvx/tracingtracingPlugin()PluginNode, Deno, Bun

Request logging

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

server.ts
import { serve } from "srvx";
import { log } from "srvx/log";

serve({
  middleware: [log()],
  fetch: () => new Response("πŸ‘‹ Hello there."),
});
[10:32:03 AM] GET http://localhost:3000/ [200] (1.42ms)

The duration is measured around next(), so place log() first in the array for it to cover the whole chain. The CLI enables this middleware automatically.

Static files

serveStatic() 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.

server.ts
import { serve } from "srvx";
import { serveStatic } from "srvx/static";

serve({
  middleware: [serveStatic({ 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.

serveStatic() 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).
  • 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. 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.

/.well-known/ is served by default because RFC 8615 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.

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

See Serving static files for the equivalent CLI flag.

Mutual TLS

mtls() from srvx/mtls requests a client certificate during the TLS handshake and exposes it on request.tls. It requires the Node.js adapter.

Tracing

tracingPlugin() from srvx/tracing wraps your fetch handler and each middleware with diagnostics_channel instrumentation, publishing to the srvx.request and srvx.middleware tracing channels.

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.

srvx/tracing is experimental.