Using CLI
You can run srvx with your preferred runtime without installation:
npx srvx
pnpx srvx
yarn dlx srvx
deno -A npm:srvx
bunx --bun srvx
Usage
srvx - Universal Server.
SERVE MODE
# srvx serve [options]
$ srvx serve --entry ./server.ts # Start development server
$ srvx serve --prod # Start production server
$ srvx serve --port=8080 # Listen on port 8080
$ srvx serve --host=localhost # Bind to localhost only
$ srvx serve --static=./dist # Serve static files (no entry needed)
$ srvx serve --import=jiti/register # Enable [jiti](https://github.com/unjs/jiti) loader
$ srvx serve --tls --cert=cert.pem --key=key.pem # Enable TLS (HTTPS/HTTP2)
FETCH MODE
# srvx fetch|curl [options] [url]
$ srvx fetch # Fetch from default entry
$ srvx fetch /api/users # Fetch a specific URL/path
$ srvx fetch --entry ./server.ts /api/users # Fetch using a specific entry
$ srvx fetch -X POST /api/users # POST request
$ srvx fetch -H "Content-Type: application/json" /api # With headers
$ srvx fetch -d '{"name":"foo"}' /api # With request body
$ srvx fetch -v /api/users # Verbose output (show headers)
$ echo '{"name":"foo"}' | srvx fetch -d @- /api # Body from stdin
COMMON OPTIONS
--entry <file> Server entry file to use
--dir <dir> Working directory for resolving entry file
-h, --help Show this help message
--version Show server and runtime versions
SERVE OPTIONS
-p, --port <port> Port to listen on (default: 3000)
--host, --hostname <host> Host to bind to (default: all interfaces)
-s, --static <dir> Serve static files from the specified directory (default: public)
--prod Run in production mode (no watch, no debug)
--import <loader> ES module to preload
--tls Enable TLS (HTTPS/HTTP2)
--cert <file> TLS certificate file
--key <file> TLS private key file
FETCH OPTIONS
-X, --method <method> HTTP method (default: GET, or POST if body is provided; --request is a curl alias)
-H, --header <header> Add header (format: "Name: Value", can be used multiple times)
-d, --data <data> Request body (use @- for stdin, @file for file)
--host <host> Host for a schemeless URL/path (default: localhost)
--tls Use https for a schemeless URL/path
-v, --verbose Show request and response headers
Exits with code 22 on a non-2xx response (like curl --fail).
ENVIRONMENT
PORT Default port to listen on
HOST Default host to bind to
NODE_ENV Set to production for production mode.
Port and host precedence
The port and host are resolved with the following precedence (highest first):
CLI flag (--port / --host / --hostname)
Module option (port / hostname exported from your server entry)
Environment variable (PORT / HOST)
Default (3000 / all interfaces)
Exit codes
In fetch mode, srvx fetch exits with code 22 for any non-2xx response, and 0 for a 2xx response.
Runtime notes
--import flag preloads an ES module (e.g. a loader like jiti/register). It is applied on Node.js and Bun only — on Deno it is silently ignored, since Deno does not support Node's --import preload flag.Serving static files
The CLI can serve a directory of static files. No server entry is required — point --static at any folder:
npx srvx --static ./dist
If --static is omitted, srvx serves files from a public/ directory when one exists, and skips static serving when it does not. Passing --static explicitly asserts the directory exists: srvx errors out if it is missing, rather than starting up and serving nothing.
When both a server entry and a static directory are present, static files take priority and unmatched requests fall through to your handler.
Static serving includes automatic index.html resolution, .html extension fallback (e.g. /about → about.html), common MIME types, gzip/Brotli compression, and path-traversal protection.
Programmatic API
Both CLI modes are built on srvx/loader. The same loader is available to you, so you can build a dev server, a test harness, or a framework CLI that accepts any server entry srvx accepts — without reimplementing entry discovery or handler detection.
import { loadServerEntry } from "srvx/loader";
import { serve } from "srvx";
const loaded = await loadServerEntry({ entry: "./server.ts" });
if (loaded.notFound) {
throw new Error("No server entry found");
}
if (!loaded.fetch) {
throw new Error("Server entry exports no fetch handler");
}
serve({ fetch: loaded.fetch });
loadServerEntry(opts) imports a server entry module and resolves a web fetch handler from it. It never throws for a missing entry — check notFound on the result instead.
Entry resolution
When entry is set, it is resolved against dir (or the current working directory). A file:// URL is used as-is.
When entry is omitted, the loader searches dir for the first match of each of these base names, trying .mjs, .js, .mts, then .ts for each:
server
server/index
src/server
server/server
Both lists are exported as defaultEntries and defaultExts if you need to reuse them (for example, to build a file watcher). If nothing matches, the result is { notFound: true } with no fetch and no module.
Handler resolution
Once the module is imported, the loader looks for a handler in this order:
module.fetch
module.default.fetch
module.default.default.fetch (a double-default from a transpiled CommonJS entry)
The fetch of a server the entry created by calling serve() (see Loading entries that start a server)
module.default, if it is a function taking fewer than two arguments
If none match and nodeCompat is not disabled, a legacy Node.js (req, res) handler is wrapped into a fetch handler and nodeCompat: true is set on the result. Both module.default and a handler captured from http.createServer() are eligible.
Loading entries that start a server
Most server entries do not export a handler — they start listening as a side effect of being imported. The loader intercepts the listen call, so importing the entry gives you its handler without binding a port.
If the entry calls srvx's own serve(), the loader hands you back that server instance as srvxServer, and its handler as fetch. The server is fully constructed but never listens. Because the entry's serve() call runs during the import, the instance does not exist yet when you pass options — use a getter to close the loop:
let server: Server | undefined;
const loaded = await loadServerEntry({
entry: "./server.ts",
get srvxServer() {
return server;
},
});
server = serve({ fetch: loaded.fetch!, port: 3000 });
Anything the entry exports alongside its handler is on loaded.module, so you can read its server options and merge them with your own.
If the entry instead calls http.createServer(handler).listen(), the loader captures handler and lets the entry run to completion without binding a port. Its listen callback still fires, so setup code that runs after listen is not skipped.
Pass interceptHttpListen: false to opt out. Note that this disables both interceptions — an entry that calls serve() will then really start listening on import.
loadServerEntry calls are queued rather than run in parallel.Reloading an entry
The handler of an intercepted entry comes from a side effect of the import, and import() caches modules per URL. Loading the same entry twice in one process returns the cached module without re-running it, so the interception never fires and fetch comes back undefined. Entries that export a handler are unaffected — their handler lives on the cached module.
If you need to load an entry more than once (a watch mode, for example), give each load a unique entry URL:
import { pathToFileURL } from "node:url";
const url = pathToFileURL(resolve("./server.ts")).href;
const loaded = await loadServerEntry({ entry: `${url}?t=${Date.now()}` });
The query is ignored when checking that the file exists, and is preserved in the returned url.
--watch handle reloads.LoadOptions
entry— Path orfile://URL of the server entry file. If omitted, the default entries are searched.dir— Base directory for resolvingentryand for auto-discovery. Default is".".nodeCompat— Set tofalseto disable upgrading a legacy Node.js(req, res)handler. The result then has nofetchwhen the entry only exports a Node.js handler. Default istrue.interceptHttpListen— Set tofalseto import the entry without intercepting listen calls. Default istrue.srvxServer— The srvx server instance to hand to the entry when itsserve()call is intercepted. Define it as a getter when the instance is created after loading.nodeServer— Thenode:httpserver instance to return from an interceptedlisten(). Defaults tosrvxServer's underlying Node.js server, or a stub that forwards to it once it exists.onLoad— Hook called with the imported module before the handler is resolved. Return a value to replace the module.
LoadedServerEntry
fetch— The resolved web fetch handler, orundefinedif the entry exports none.module— The raw imported module. Use it to read options the entry exports next to its handler.url— The resolvedfile://URL of the loaded entry.notFound—truewhen no entry file could be located.fetchandmoduleare thenundefined.nodeCompat—truewhen the handler was upgraded from a legacy Node.js(req, res)handler. Serve it withsrvx/node.srvxServer— The server instance the entry created viaserve(), if that call was intercepted.
TypeScript entries
The loader imports entries with a plain dynamic import(), so TypeScript support comes from the runtime: