Middleware
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.
| Import | Export | Kind | Runtimes |
|---|---|---|---|
srvx/log | log() | Middleware | All |
srvx/static | serveStatic() | Middleware | Node, Deno, Bun |
srvx/mtls | mtls() | Plugin | Node |
srvx/tracing | tracingPlugin() | Plugin | Node, Deno, Bun |
Request logging
log() from srvx/log prints one colored line per request with the method, URL, status, and duration.
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.
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 tonext().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 tonext(). Passtrueto serve every dot segment, orfalse(or[]) to serve none, including/.well-known/.encodings: Serve precompressed variants from disk (defaultfalse). Passtruefor{ 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 astatthat always misses.compress: Compress a response on the fly when no precompressed variant is served (defaulttrue). Passfalseto serve only what is already on disk.renderHTML: A function receiving{ request, html, filename }for every HTML file (.html,.htm), returning theResponseto 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.
/.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.
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.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.
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.