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/logloggerMiddleware()MiddlewareAll
srvx/staticstaticMiddleware()MiddlewareNode, Deno, Bun
srvx/mtlsmtlsPlugin()PluginNode
srvx/tracingtracingPlugin()PluginNode, Deno, Bun

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 is set or output is not a TTY. Set FORCE_COLOR to keep them in any of those cases.

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

serve({
  middleware: [loggerMiddleware()],
  fetch: () => new Response("πŸ‘‹ Hello there."),
});
[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 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.
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.

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

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