Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 160 additions & 27 deletions docs/1.guide/10.cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,58 +60,191 @@ $ 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
--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 <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
-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, --request <method> HTTP method (default: GET, or POST if body is provided)
-H, --header <header> Add header (format: "Name: Value", can be used multiple times)
-d, --data <data> Request body (use @- for stdin, @file for file)
-v, --verbose Show request and response headers
-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 Override port
HOST Override host
NODE_ENV Set to production for production mode.
PORT Default port to listen on
HOST Default host to bind to
NODE_ENV Set to production for production mode.
```

<!-- /automd -->

## Port and host precedence

The port and host are resolved with the following precedence (highest first):

1. CLI flag (`--port` / `--host` / `--hostname`)
2. Module option (`port` / `hostname` exported from your server entry)
3. Environment variable (`PORT` / `HOST`)
4. 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

> [!NOTE]
> 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, similar to [`serve`](https://github.com/vercel/serve). No server entry is required &mdash; point `--static` at any folder:
The CLI can serve a directory of static files. No server entry is required &mdash; point `--static` at any folder:

```bash
npx srvx --static ./dist
```

If `--static` is omitted, srvx serves files from a `public/` directory when one exists. When both a server entry and a static directory are present, static files take priority and unmatched requests fall through to your handler.
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.

For programmatic usage, import the [`serveStatic`](https://github.com/h3js/srvx/blob/main/src/static.ts) middleware:
## 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.

```ts [server.ts]
```ts
import { loadServerEntry } from "srvx/loader";
import { serve } from "srvx";
import { serveStatic } from "srvx/static";

serve({
middleware: [serveStatic({ dir: "public" })],
fetch: () => new Response("Not found", { status: 404 }),
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 &mdash; 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:

1. `server`
2. `server/index`
3. `src/server`
4. `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:

1. `module.fetch`
2. `module.default.fetch`
3. `module.default.default.fetch` (a double-default from a transpiled CommonJS entry)
4. The `fetch` of a server the entry created by calling `serve()` (see [Loading entries that start a server](#loading-entries-that-start-a-server))
5. `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 &mdash; 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 &mdash; use a getter to close the loop:

```ts
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](/guide/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 &mdash; an entry that calls `serve()` will then really start listening on import.

> [!NOTE]
> Entries are intercepted one at a time &mdash; 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 &mdash; 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:

```ts
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`.

> [!NOTE]
> 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`** &mdash; Path or `file://` URL of the server entry file. If omitted, the [default entries](#entry-resolution) are searched.
- **`dir`** &mdash; Base directory for resolving `entry` and for auto-discovery. Default is `"."`.
- **`nodeCompat`** &mdash; 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`** &mdash; Set to `false` to import the entry without intercepting listen calls. Default is `true`.
- **`srvxServer`** &mdash; 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`** &mdash; 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`** &mdash; Hook called with the imported module before the handler is resolved. Return a value to replace the module.

### `LoadedServerEntry`

- **`fetch`** &mdash; The resolved web fetch handler, or `undefined` if the entry exports none.
- **`module`** &mdash; The raw imported module. Use it to read options the entry exports next to its handler.
- **`url`** &mdash; The resolved `file://` URL of the loaded entry.
- **`notFound`** &mdash; `true` when no entry file could be located. `fetch` and `module` are then `undefined`.
- **`nodeCompat`** &mdash; `true` when the handler was upgraded from a legacy Node.js `(req, res)` handler. Serve it with `srvx/node`.
- **`srvxServer`** &mdash; 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** &mdash; 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** &mdash; supported natively.
- **JSX** &mdash; needs a loader such as [jiti](https://github.com/unjs/jiti), preloaded with the [`--import`](#usage) flag.
2 changes: 1 addition & 1 deletion docs/1.guide/2.handler.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ import { serve } from "srvx";
serve({
fetch: (request) => {
if (request.runtime.node) {
console.log("Node.js req path:", request.runtime.node?.req.path);
console.log("Node.js req url:", request.runtime.node?.req.url);
request.runtime.node.res.statusCode = 418; // I'm a teapot!
}
return new Response("ok");
Expand Down
15 changes: 1 addition & 14 deletions docs/1.guide/3.server.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,6 @@ Access to the sever options set during initialization.

Get the computed server listening URL.

### `server.addr`

Listening address (hostname or ipv4/ipv6).

### `server.port`

Listening port number.

:read-more{to="/guide/options#port-required"}

### `server.waitUntil?`

Register a background task that the server should await before closing. This is the same function as [`request.waitUntil`](/guide/handler#requestwaituntil) but available at the server level for use outside of request handlers.
Expand Down Expand Up @@ -98,9 +88,6 @@ await server.close(true);

## Access to the Underlying Server

> [!NOTE]
> srvx tries to translate most common options to op level properties. This is only for advanced usage.

### `server.bun.server`

Access to the underlying Bun server instance when running in Bun.
Expand All @@ -109,7 +96,7 @@ Access to the underlying Bun server instance when running in Bun.

### `server.deno.server`

Access to the underlying Bun server instance when running in Deno.
Access to the underlying Deno server instance when running in Deno.

:read-more{to="https://docs.deno.com/api/deno/~/Deno.HttpServer"}

Expand Down
96 changes: 94 additions & 2 deletions docs/1.guide/4.middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,98 @@ serve({
});
```

## Built-in plugins
## Order of execution

srvx ships an [`mtls()`](/guide/tls#mutual-tls-mtls) plugin from `srvx/mtls` that requests a client certificate (mutual TLS) and exposes it on [`request.tls`](/guide/handler#requesttls). It requires the [Node.js adapter](/guide/node).
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.

```ts [server.ts]
import { serve } from "srvx";
import { log } from "srvx/log";

serve({
middleware: [log()],
fetch: () => new Response("👋 Hello there."),
});
```

```sh
[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](/guide/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.

```ts [server.ts]
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 to `next()`.
- `renderHTML`: A function receiving `{ request, html, filename }` for every `.html` file, returning the `Response` to send. Use it to inject or template markup before serving.

> [!NOTE]
> Despite the runtime-neutral name, `srvx/static` is **Node-API-only** — it uses `node:fs` and `node:zlib` internally, so it works only on runtimes with Node.js compatibility (Node, Deno, Bun).

See [Serving static files](/guide/cli#serving-static-files) for the equivalent CLI flag.

### Mutual TLS

[`mtls()`](/guide/tls#mutual-tls-mtls) from `srvx/mtls` requests a client certificate during the TLS handshake and exposes it on [`request.tls`](/guide/handler#requesttls). It requires the [Node.js adapter](/guide/node).

### Tracing

`tracingPlugin()` from `srvx/tracing` wraps your `fetch` handler and each middleware with [`diagnostics_channel`](https://nodejs.org/api/diagnostics_channel.html) instrumentation, publishing to the `srvx.request` and `srvx.middleware` [tracing channels](https://nodejs.org/api/diagnostics_channel.html#class-tracingchannel).

```ts [server.ts]
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.

> [!IMPORTANT]
> `srvx/tracing` is **experimental**.
Loading
Loading