Using CLI

Use the srvx CLI command to easily start a development or production server.

You can run srvx with your preferred runtime without installation:

npx 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

The --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. /aboutabout.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.

Entries are intercepted one at a time — concurrent 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.

Each reload leaves the previous module in the ESM cache for the lifetime of the process. For a long-running watch mode, restart a child process instead and let the runtime's own --watch handle reloads.

LoadOptions

  • entry — Path or file:// URL of the server entry file. If omitted, the default entries are searched.
  • dir — Base directory for resolving entry and for auto-discovery. Default is ".".
  • nodeCompat — Set to false to disable upgrading a legacy Node.js (req, res) handler. The result then has no fetch when the entry only exports a Node.js handler. Default is true.
  • interceptHttpListen — Set to false to import the entry without intercepting listen calls. Default is true.
  • srvxServer — The srvx server instance to hand to the entry when its serve() call is intercepted. Define it as a getter when the instance is created after loading.
  • nodeServer — The node:http server instance to return from an intercepted listen(). Defaults to srvxServer'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, or undefined if the entry exports none.
  • module — The raw imported module. Use it to read options the entry exports next to its handler.
  • url — The resolved file:// URL of the loaded entry.
  • notFoundtrue when no entry file could be located. fetch and module are then undefined.
  • nodeCompattrue when the handler was upgraded from a legacy Node.js (req, res) handler. Serve it with srvx/node.
  • srvxServer — The server instance the entry created via serve(), if that call was intercepted.

TypeScript entries

The loader imports entries with a plain dynamic import(), so TypeScript support comes from the runtime:

  • Node.js — requires v22.18+ or v24+ for .ts entries. The loader throws a targeted error on older versions rather than a raw ERR_UNKNOWN_FILE_EXTENSION.
  • Deno and Bun — supported natively.
  • JSX — needs a loader such as jiti, preloaded with the --import flag.