Skip to content
11 changes: 11 additions & 0 deletions docs/1.guide/10.cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ $ 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 --static=./dist --no-dir-listing # ...without the dev directory listing
$ srvx serve --prod --dir-listing # Enable the directory listing in production
$ 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)

Expand Down Expand Up @@ -70,6 +72,8 @@ 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)
--dir-listing Serve a directory listing for index-less directories (default: on in dev, off with --prod)
--no-dir-listing Disable the directory listing (e.g. in dev)
--prod Run in production mode (no watch, no debug)
--import <loader> ES module to preload
--tls Enable TLS (HTTPS/HTTP2)
Expand Down Expand Up @@ -128,6 +132,13 @@ When both a server entry and a static directory are present, static files take p

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.

In dev mode, a request for a directory with no index file returns a generated HTML listing of its contents, so you can browse a folder without an `index.html`. The listing is a last-resort fallback: static files are tried first, then your server handler runs, and only a `404` response falls back to the listing — a real route (or a custom 404 page for a non-directory path) always wins. This is a dev convenience only: it is disabled under `--prod`, so the directory structure is never exposed in production. Override the default either way with `--dir-listing` (force it on, e.g. in production) or `--no-dir-listing` (force it off in dev). It maps to the [`dirListing`](/guide/middleware#static-files) option of `serveStatic()`.

```bash
npx srvx --static ./dist --prod --dir-listing # opt in under --prod
npx srvx --static ./dist --no-dir-listing # opt out in dev
```

## 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 &mdash; without reimplementing entry discovery or handler detection.
Expand Down
3 changes: 3 additions & 0 deletions docs/1.guide/4.middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,13 @@ When no file matches the request, it calls `next()` — so your handler acts as
- `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.
- `dirListing`: Serve a minimal HTML directory listing as a 404 fallback for a directory with no index file (default `false`). The rest of the app answers first; only a 404 is replaced by the listing. Off by default because it exposes the directory structure — it is opt-in. The [CLI](/guide/cli#serving-static-files) turns it on in dev mode by default.
- `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`.

With `dirListing: true`, a request naming a directory with no index file — the root, a trailing-slash path, or an extension-less one — still falls through to `next()` first, and a generated HTML listing replaces the response only when it comes back `404`. A real route always wins over a listing, and a custom 404 page keeps working: it is replaced only when the path actually names a listable directory, and passes through untouched everywhere else. An index always wins, so a directory with an `index.html` still serves it. Entries are the directory's immediate children, sorted directories-first; a denied dot segment (`.env`, `.git`) is hidden from the listing exactly as it is from a direct request, so a listing never names anything the middleware would refuse to serve. Links are absolute paths, so they resolve the same whether the directory was requested with a trailing slash or without, and the page follows the OS light/dark theme. The listing carries `X-Robots-Tag: noindex, nofollow` (mirrored by a `robots` meta tag) and a strict `Content-Security-Policy` — it is a self-contained page with no scripts or external resources — plus `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, and `Cache-Control: no-store` so a browser never shows a stale listing after files change. It is off by default because it reveals the directory structure; the [CLI](/guide/cli#serving-static-files) enables it in dev mode by default.

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.
Expand Down
10 changes: 9 additions & 1 deletion src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ function parseArgs(args: string[]): CLIOptions {
prod: { type: "boolean" },
port: { type: "string", short: "p" },
static: { type: "string", short: "s" },
// `parseArgs` has no native `--no-*` negation, so the opt-out is its own
// flag; the two collapse into a tri-state `dirListing` below.
"dir-listing": { type: "boolean" },
"no-dir-listing": { type: "boolean" },
import: { type: "string" },
cert: { type: "string" },
key: { type: "string" },
Expand Down Expand Up @@ -142,6 +146,10 @@ function parseArgs(args: string[]): CLIOptions {
return { mode, ...values, url, method };
}

// Collapse the two listing flags into a tri-state: explicit on/off, or
// `undefined` to leave the dev/prod default to `cliServe`.
const dirListing = values["dir-listing"] ? true : values["no-dir-listing"] ? false : undefined;

// Serve mode: allow entry or dir as a positional argument
const maybeEntryOrDir = positionals[0];
if (maybeEntryOrDir) {
Expand All @@ -159,7 +167,7 @@ function parseArgs(args: string[]): CLIOptions {
}
}

return { mode, ...values };
return { mode, ...values, dirListing };
}

async function startServer(cliOpts: CLIOptions) {
Expand Down
18 changes: 12 additions & 6 deletions src/cli/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,17 +81,23 @@ export async function cliServe(cliOpts: CLIOptions): Promise<void> {
},
fetch:
loaded.fetch ||
(() =>
renderError(
cliOpts,
loaded.notFound ? "Server Entry Not Found" : "No Fetch Handler Exported",
501,
)),
(loaded.notFound
? // Static-only mode (no entry): an unmatched request is an ordinary
// 404, not a server misconfiguration — and 404 is what the static
// middleware's `dirListing` fallback keys on.
() => new Response("Not Found", { status: 404 })
: () => renderError(cliOpts, "No Fetch Handler Exported", 501)),
middleware: [
log(),
cliOpts.static
? serveStatic({
dir: cliOpts.static,
// Dev convenience: browse directories without an index. A 404
// fallback — static files win first, then the user handler runs,
// and only a 404 falls back to the listing. Off in prod so the
// structure is never exposed by default, unless the explicit
// `--dir-listing` / `--no-dir-listing` flag overrides either way.
dirListing: cliOpts.dirListing ?? !cliOpts.prod,
})
: undefined,
...(serverOptions.middleware || []),
Expand Down
6 changes: 6 additions & 0 deletions src/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export type CLIOptions = {
prod?: boolean;
/** Serve static files from the specified directory (default: "public") */
static?: string;
/**
* Serve an HTML directory listing for directories without an index file.
* Defaults to on in dev mode and off with `--prod`; set explicitly to override
* either way (`--dir-listing` / `--no-dir-listing`).
*/
dirListing?: boolean;
/** ES module to preload */
import?: string;
/** Host to bind to (default: all interfaces) */
Expand Down
4 changes: 4 additions & 0 deletions src/cli/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ ${c.gray("$")} ${c.cyan(command)} serve --prod ${c.gray("# Start
${c.gray("$")} ${c.cyan(command)} serve --port=8080 ${c.gray("# Listen on port 8080")}
${c.gray("$")} ${c.cyan(command)} serve --host=localhost ${c.gray("# Bind to localhost only")}
${c.gray("$")} ${c.cyan(command)} serve --static=./dist ${c.gray("# Serve static files (no entry needed)")}
${c.gray("$")} ${c.cyan(command)} serve --static=./dist --no-dir-listing ${c.gray("# ...without the dev directory listing")}
${c.gray("$")} ${c.cyan(command)} serve --prod --dir-listing ${c.gray("# Enable the directory listing in production")}
${c.gray("$")} ${c.cyan(command)} serve --import=jiti/register ${c.gray(`# Enable ${c.url("jiti", "https://github.com/unjs/jiti")} loader`)}
${c.gray("$")} ${c.cyan(command)} serve --tls --cert=cert.pem --key=key.pem ${c.gray("# Enable TLS (HTTPS/HTTP2)")}

Expand Down Expand Up @@ -47,6 +49,8 @@ ${c.bold("SERVE OPTIONS")}
${c.green("-p, --port")} ${c.yellow("<port>")} Port to listen on (default: ${c.yellow("3000")})
${c.green("--host, --hostname")} ${c.yellow("<host>")} Host to bind to (default: all interfaces)
${c.green("-s, --static")} ${c.yellow("<dir>")} Serve static files from the specified directory (default: ${c.yellow("public")})
${c.green("--dir-listing")} Serve a directory listing for index-less directories (default: on in dev, off with ${c.green("--prod")})
${c.green("--no-dir-listing")} Disable the directory listing (e.g. in dev)
${c.green("--prod")} Run in production mode (no watch, no debug)
${c.green("--import")} ${c.yellow("<loader>")} ES module to preload
${c.green("--tls")} Enable TLS (HTTPS/HTTP2)
Expand Down
Loading
Loading