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 | loggerMiddleware() | Middleware | All |
srvx/static | staticMiddleware() | Middleware | Node, Deno, Bun |
srvx/mtls | mtlsPlugin() | Plugin | Node |
srvx/tracing | tracingPlugin() | Plugin | Node, 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.
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 tofalseto 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.
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 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.lastModified: Emit aLast-Modifiedheader from the file's modification time, and answer a matchingIf-Modified-Sincerequest with304 Not Modified(defaulttrue).etag: Emit a weakETagvalidator, and answer a matchingIf-None-Matchrequest with304 Not Modified(defaulttrue).maxAge: Freshness lifetime in seconds, emitted asCache-Control: max-age=<n>(defaultundefined, no header). Lets a client reuse a response without a request until it goes stale.immutable: Add theimmutabledirective toCache-Control, so a client does not revalidate a still-fresh response even on reload (defaultfalse). Only takes effect alongsidemaxAge, and only makes sense for fingerprinted (content-hashed) assets.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.
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.
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
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.
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.