From f6154d5f710089a8068f8c43c92f26791975136c Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Tue, 14 Jul 2026 12:47:13 +0000 Subject: [PATCH 01/10] docs: v1 stabilization batch Docs scope of the v1 stabilization plan (F54, F14, F53, F11, F55, F56, CLI, support matrix, skipped-test issue links). - server.md: delete non-existent `server.addr` / `server.port` sections and the stale `#port-required` anchor (F54); fix "Bun instance in Deno" copy-paste error. - handler.md: `request.runtime.node?.req.path` -> `.url` (F54). - options.md: `maxHeadersize` -> `maxHeaderSize`; document `hostname` HOST env default (F14); document `manual`, `middleware`, `plugins`, `serviceWorker`; add per-runtime support matrix (close(true) on Deno, trustProxy scope, Bun 128 MiB / loader-drop for maxRequestBodySize, manual on workers, Cloudflare env in SW syntax, no upgrade/WebSocket -> crossws); Deno 2.x range + explicit `srvx/bunny` import note; public subpaths table; per-runtime `@types` note (F56); note `srvx/static` is Node-API-only. - node.md: state `_request`/`_url` are supported API frozen at v1 (D7/F53); `toFetchHandler`/`fetchNodeHandler` stable, `srvx/tracing` experimental (D5/F11); add Semver policy section incl. ERR_BODY_TOO_LARGE-only code guarantee (F55). - cli: `usage.ts` env wording (default, not override), document `--hostname`, fetch `--host`/`--tls`, `--method` (+`--request` alias), exit-22 line; cli.md precedence, exit-code divergence from curl, and Deno `--import` drop. - types.ts: fix "server with listen" JSDoc typo + HOST default (F14). - Link skipped protocol tests to tracking issues (#235, #236, #237). Co-Authored-By: Claude Fable 5 --- docs/1.guide/10.cli.md | 36 +++++++++-- docs/1.guide/2.handler.md | 2 +- docs/1.guide/3.server.md | 12 +--- docs/1.guide/5.options.md | 128 +++++++++++++++++++++++++++++++++++++- docs/1.guide/8.node.md | 21 ++++++- src/cli/usage.ts | 12 ++-- src/types.ts | 3 +- test/_tests.ts | 4 +- test/node.test.ts | 2 +- 9 files changed, 193 insertions(+), 27 deletions(-) diff --git a/docs/1.guide/10.cli.md b/docs/1.guide/10.cli.md index 07004419..ee4f31cc 100644 --- a/docs/1.guide/10.cli.md +++ b/docs/1.guide/10.cli.md @@ -68,7 +68,7 @@ COMMON OPTIONS SERVE OPTIONS -p, --port Port to listen on (default: 3000) - --host Host to bind to (default: all interfaces) + --host, --hostname Host to bind to (default: all interfaces) -s, --static Serve static files from the specified directory (default: public) --prod Run in production mode (no watch, no debug) --import ES module to preload @@ -78,20 +78,45 @@ SERVE OPTIONS FETCH OPTIONS - -X, --request HTTP method (default: GET, or POST if body is provided) + -X, --method HTTP method (default: GET, or POST if body is provided; --request is a curl alias) -H, --header
Add header (format: "Name: Value", can be used multiple times) -d, --data Request body (use @- for stdin, @file for file) + --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 + 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): + +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. + +> [!IMPORTANT] +> This diverges from `curl`, which exits `0` regardless of the HTTP status unless you pass `--fail`. With `srvx fetch` the non-2xx exit is the **default**, so no extra flag is needed to make a failing request fail your script. + +## 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 — point `--static` at any folder: @@ -104,6 +129,9 @@ If `--static` is omitted, srvx serves files from a `public/` directory when one 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. +> [!NOTE] +> `srvx/static` is **Node-API-only** β€” it uses `node:fs` and `node:zlib` internally, so despite the runtime-neutral name it only works on runtimes with Node.js compatibility (Node, Deno, Bun). + For programmatic usage, import the [`serveStatic`](https://github.com/h3js/srvx/blob/main/src/static.ts) middleware: ```ts [server.ts] diff --git a/docs/1.guide/2.handler.md b/docs/1.guide/2.handler.md index 66905912..e26c30e2 100644 --- a/docs/1.guide/2.handler.md +++ b/docs/1.guide/2.handler.md @@ -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"); diff --git a/docs/1.guide/3.server.md b/docs/1.guide/3.server.md index 77deb8a6..942acec4 100644 --- a/docs/1.guide/3.server.md +++ b/docs/1.guide/3.server.md @@ -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. @@ -109,7 +99,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"} diff --git a/docs/1.guide/5.options.md b/docs/1.guide/5.options.md index c57ec74c..74b88d90 100644 --- a/docs/1.guide/5.options.md +++ b/docs/1.guide/5.options.md @@ -46,7 +46,7 @@ Default is value of `PORT` environment variable or `3000`. The hostname (IP or resolvable host) server listener should bound to. -When not provided, server will **listen to all network interfaces** by default. +Default is value of the `HOST` environment variable, if set. When neither `hostname` nor `HOST` is provided, the server will **listen to all network interfaces** by default. > [!IMPORTANT] > If you are running a server that should not be exposed to the network, use `localhost`. @@ -62,6 +62,66 @@ Enabling this option allows multiple processes to bind to the same port, which i If enabled, no server listening message will be printed (enabled by default when `TEST` environment variable is set). +### `manual` + +If set to `true`, the server will **not** start listening automatically. You must call [`server.serve()`](/guide/server#serverserve) yourself to begin accepting connections. Useful when you need to fully construct the server before it starts. + +```js +import { serve } from "srvx"; + +const server = serve({ + manual: true, + fetch: () => new Response("πŸ‘‹ Hello there!"), +}); + +// ...later +await server.serve(); +await server.ready(); +``` + +> [!NOTE] +> `manual` is meaningless on module-worker style runtimes (Cloudflare module syntax, Bunny), where the runtime drives the request lifecycle and there is no listening step to defer. + +### `middleware` + +An array of [middleware](/guide/middleware) handlers to run before the main `fetch` handler. + +```js +import { serve } from "srvx"; + +serve({ + middleware: [ + (request, next) => { + console.log(`[${request.method}] ${request.url}`); + return next(); + }, + ], + fetch: () => new Response("πŸ‘‹ Hello there!"), +}); +``` + +:read-more{to="/guide/middleware" title="Middleware"} + +### `plugins` + +An array of [plugins](/guide/middleware) to extend the server. A plugin is a **synchronous** function that receives the server instance and can register middleware, hook into the lifecycle, or augment requests. + +```js +import { serve } from "srvx"; + +const myPlugin = (server) => { + server.options.middleware.push((request, next) => next()); +}; + +serve({ + plugins: [myPlugin], + fetch: () => new Response("πŸ‘‹ Hello there!"), +}); +``` + +> [!NOTE] +> Plugins are sync-only (they return `void`). All plugin-registered middleware is therefore in place before the first request is handled. + ### `protocol` The protocol to use for the server. @@ -193,7 +253,7 @@ import { serve } from "srvx"; serve({ node: { - maxHeadersize: 16384 * 2, // Double default + maxHeaderSize: 16384 * 2, // Double default ipv6Only: true, // Disable dual-stack support // http2: false // Disable http2 support (enabled by default in TLS mode) }, @@ -250,3 +310,67 @@ serve({ ::read-more{to=https://docs.deno.com/api/deno/~/Deno.ServeOptions} See Deno serve documentation for all available options. :: + +### Service Worker + +Options for the service worker adapter (`srvx/service-worker`). + +```js +import { serve } from "srvx/service-worker"; + +serve({ + serviceWorker: { + url: "/sw.js", // path to the service worker file to register + scope: "/", // service worker scope + }, + fetch: () => new Response("πŸ‘‹ Hello there!"), +}); +``` + +- `url`: The path to the service worker file to be registered. +- `scope`: The scope of the service worker. + +## Per-runtime Support + +srvx aims for identical behavior across runtimes, but a few options depend on capabilities the underlying runtime does not expose. The table below lists the intentional differences. + +| Option / feature | Behavior | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `close(true)` (force close) | Honored on Node and Bun. On **Deno** the force argument is currently ignored β€” `close(true)` behaves like a graceful `close()`. | +| `trustProxy` | Applies to the **Node, AWS Lambda, Bun and Deno** adapters only. Ignored on Cloudflare, Bunny and the generic/service-worker adapters. | +| `maxRequestBodySize` | Node/Deno enforced by srvx (unlimited by default). **Bun** forwards it to Bun's native option, which has its own **128 MiB** default even when unset. Dropped (not applied) when running under the CLI loader on any runtime. | +| `manual` | Meaningless on module-worker runtimes (Cloudflare module syntax, Bunny) β€” there is no listening step to defer. | +| `gracefulShutdown` | Supported on Node, Deno and Bun. No-op on Cloudflare, Bunny and service-worker runtimes. | +| Cloudflare `env` bindings | `request.runtime.cloudflare.env` is only populated in **module-worker syntax**. In service-worker syntax (the global `fetch` listener) bindings are unavailable. | +| **`upgrade` / WebSocket** | srvx does **not** handle HTTP `upgrade` requests on **any** runtime β€” WebSocket upgrades never reach your `fetch` handler. Use [crossws](https://crossws.h3.dev/) for WebSocket support. | + +### Deno version + +srvx targets **Deno 2.x** (CI runs against Deno 2.7.x). + +> [!IMPORTANT] +> Under the Bunny runtime, a root `srvx` import resolves the `deno` export condition (Bunny is Deno-based) and would give you the Deno adapter. Always import the Bunny adapter explicitly via `srvx/bunny`. + +## Public Subpaths + +In addition to the main `srvx` entry and the runtime adapters, srvx exposes several public subpaths: + +| Subpath | Exports | Use when | +| --------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `srvx/static` | `serveStatic` middleware | Serving static files. **Node-API-only** (uses `node:fs` / `node:zlib`) despite the neutral name β€” works only on runtimes with Node compatibility. | +| `srvx/log` | `log()` middleware | Adding request logging (used by the CLI). | +| `srvx/loader` | `loadServerEntry`, related types | Resolving and loading a server entry file (as the CLI does). | +| `srvx/cli` | `main`, `cliFetch`, CLI types | Building a custom CLI on top of srvx. | +| `srvx/generic` | `serve`, `FastURL`, `FastResponse` | A web-standard (`fetch`/`Response`) runtime with no dedicated adapter. | +| `srvx/service-worker` | `serve`, `FastURL`, `FastResponse` | Running inside a browser/service-worker environment. | + +## TypeScript Types + +Runtime-specific option and context types are typed against each runtime's own type packages, which srvx does **not** bundle. To get full typing for `options.bun`, `options.deno`, `request.runtime.cloudflare`, `request.runtime.awsLambda` (and similar), install the relevant `devDependencies` in your project: + +- **Bun** (`options.bun`): [`@types/bun`](https://www.npmjs.com/package/@types/bun) (or `bun-types`) +- **Deno** (`options.deno`, `request.runtime.deno`): [`@types/deno`](https://www.npmjs.com/package/@types/deno) +- **Cloudflare** (`request.runtime.cloudflare`): [`@cloudflare/workers-types`](https://www.npmjs.com/package/@cloudflare/workers-types) +- **AWS Lambda** (`request.runtime.awsLambda`): [`@types/aws-lambda`](https://www.npmjs.com/package/@types/aws-lambda) + +Without these, the corresponding fields fall back to `any` (with `skipLibCheck`) or raise type errors (without it). diff --git a/docs/1.guide/8.node.md b/docs/1.guide/8.node.md index 04eff90c..c9882080 100644 --- a/docs/1.guide/8.node.md +++ b/docs/1.guide/8.node.md @@ -80,7 +80,10 @@ It also extends the global `Request` class provided by Node.js (via [undici](htt Due to undici’s internal implementation, some edge cases exist. For example, calling `new Request(req)` may throw `Cannot read private member #state from an object whose class did not declare it`. -To avoid these issues, you can use `req.clone()` or `req._request` to access an undici instance of request. +To avoid these issues, you can use `req.clone()` or `req._request` to access an undici instance of request. Alongside it, `req._url` exposes the already-parsed `URL` for the request. + +> [!NOTE] +> `request._request` and `request._url` are **supported, public API** β€” the leading underscore is legacy naming, not a privacy marker. Their shape is frozen under the v1 [semver policy](#semver-policy), so you can rely on them. Alternatively, you can patch the global `Request` class in Node.js. @@ -147,6 +150,22 @@ const res = await fetchNodeHandler(app, new Request("http://localhost/")); console.log(res.status, res.statusText, await res.text()); ``` +> [!NOTE] +> `toFetchHandler` and `fetchNodeHandler` are **stable** and frozen under the v1 [semver policy](#semver-policy). By contrast, the tracing plugin (`srvx/tracing`) is **experimental** and excluded from the v1 guarantee β€” its channel names and event types may change in a minor release. + +## Semver policy + +Starting with v1, srvx follows [semantic versioning](https://semver.org/). The following are covered by the stability guarantee (no breaking changes in a minor or patch release): + +- The `serve()` options and the `Server` / `ServerRequest` interfaces. +- `request._request` and `request._url` (supported API β€” see [above](#noderequest)). +- The `toFetchHandler` and `fetchNodeHandler` utilities from `srvx/node`. + +The following are **explicitly excluded** from the v1 guarantee: + +- **`srvx/tracing`** β€” experimental. Channel names and event types may change in a minor release. +- **Error `code`s** β€” only `ERR_BODY_TOO_LARGE` (thrown when `maxRequestBodySize` is exceeded) is a guaranteed, stable `code`. Other thrown errors carry no guaranteed `code`, and adding new codes later is considered a non-breaking change. + [Node.js]: https://nodejs.org/ [Deno]: https://deno.com/ [Bun]: https://bun.sh/ diff --git a/src/cli/usage.ts b/src/cli/usage.ts index ab6aafb7..c4ad7565 100644 --- a/src/cli/usage.ts +++ b/src/cli/usage.ts @@ -45,7 +45,7 @@ ${c.bold("COMMON OPTIONS")} ${c.bold("SERVE OPTIONS")} ${c.green("-p, --port")} ${c.yellow("")} Port to listen on (default: ${c.yellow("3000")}) - ${c.green("--host")} ${c.yellow("")} Host to bind to (default: all interfaces) + ${c.green("--host, --hostname")} ${c.yellow("")} Host to bind to (default: all interfaces) ${c.green("-s, --static")} ${c.yellow("")} Serve static files from the specified directory (default: ${c.yellow("public")}) ${c.green("--prod")} Run in production mode (no watch, no debug) ${c.green("--import")} ${c.yellow("")} ES module to preload @@ -55,15 +55,19 @@ ${c.bold("SERVE OPTIONS")} ${c.bold("FETCH OPTIONS")} - ${c.green("-X, --request")} ${c.yellow("")} HTTP method (default: ${c.yellow("GET")}, or ${c.yellow("POST")} if body is provided) + ${c.green("-X, --method")} ${c.yellow("")} HTTP method (default: ${c.yellow("GET")}, or ${c.yellow("POST")} if body is provided; ${c.green("--request")} is a curl alias) ${c.green("-H, --header")} ${c.yellow("
")} Add header (format: "Name: Value", can be used multiple times) ${c.green("-d, --data")} ${c.yellow("")} Request body (use ${c.yellow("@-")} for stdin, ${c.yellow("@file")} for file) + ${c.green("--host")} ${c.yellow("")} Host for a schemeless URL/path (default: ${c.yellow("localhost")}) + ${c.green("--tls")} Use ${c.yellow("https")} for a schemeless URL/path ${c.green("-v, --verbose")} Show request and response headers + Exits with code ${c.yellow("22")} on a non-2xx response (like ${c.cyan("curl --fail")}). + ${c.bold("ENVIRONMENT")} - ${c.green("PORT")} Override port - ${c.green("HOST")} Override host + ${c.green("PORT")} Default port to listen on + ${c.green("HOST")} Default host to bind to ${c.green("NODE_ENV")} Set to ${c.yellow("production")} for production mode. ${mainOpts.usage?.docs ? `➀ ${c.url("Documentation", mainOpts.usage.docs)}` : ""} diff --git a/src/types.ts b/src/types.ts index c671c5e0..6e395846 100644 --- a/src/types.ts +++ b/src/types.ts @@ -89,7 +89,8 @@ export interface ServerOptions { /** * The hostname (IP or resolvable host) server listener should bound to. * - * When not provided, server with listen to all network interfaces by default. + * Default is read from the `HOST` environment variable. When neither is + * provided, the server will listen to all network interfaces by default. * * **Important:** If you are running a server that is not expected to be exposed to the network, use `hostname: "localhost"`. */ diff --git a/test/_tests.ts b/test/_tests.ts index ec8cffb0..98e0bcf4 100644 --- a/test/_tests.ts +++ b/test/_tests.ts @@ -179,7 +179,7 @@ export function addTests(opts: { }); }); - // TODO: Investigate writing test for HTTP2/TLS + // TODO: Investigate writing test for HTTP2/TLS (https://github.com/h3js/srvx/issues/235) test.skipIf(opts.http2)("response stream error", async () => { const res = await fetch(url("/response/stream-error")); expect(res.status).toBe(200); @@ -374,7 +374,7 @@ export function addTests(opts: { expect(data.pathname).toBe("/bar/baz"); }); - // TODO: Write test to make sure it is forbidden for http2/tls + // TODO: Write test to make sure it is forbidden for http2/tls (https://github.com/h3js/srvx/issues/236) test.skipIf(opts.http2)("absolute path in request line", async () => { const _url = new URL(url("/")); diff --git a/test/node.test.ts b/test/node.test.ts index 7d6ce590..03efa327 100644 --- a/test/node.test.ts +++ b/test/node.test.ts @@ -40,7 +40,7 @@ const testConfigs = [ for (const config of testConfigs) { if ((isDeno || isBun) && config.http2) { - continue; // Not implemented yet in Deno, Bun fails somehow too! + continue; // Not implemented yet in Deno, Bun fails somehow too! (https://github.com/h3js/srvx/issues/237) } describe.sequential(`${runtime} (${config.name})`, () => { const client = getHttpClient(config.http2); From 927f190aae078ec3461e0d97c38a923684983fc7 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 16 Jul 2026 14:41:08 +0000 Subject: [PATCH 02/10] up --- docs/1.guide/1.index.md | 28 +++--- docs/1.guide/10.cli.md | 164 ++++++++++++++++++++++++++++++----- docs/1.guide/4.middleware.md | 96 +++++++++++++++++++- docs/1.guide/5.options.md | 36 +------- src/cli/usage.ts | 42 ++++----- 5 files changed, 272 insertions(+), 94 deletions(-) diff --git a/docs/1.guide/1.index.md b/docs/1.guide/1.index.md index d691f4b1..a3b4f96c 100644 --- a/docs/1.guide/1.index.md +++ b/docs/1.guide/1.index.md @@ -87,20 +87,20 @@ bun run server.mjs -| Example | Source | Try | -| ---------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | -| `aws-lambda` | [examples/aws-lambda](https://github.com/h3js/srvx/tree/main/examples/aws-lambda/) | `npx giget gh:h3js/srvx/examples/aws-lambda srvx-aws-lambda` | -| `elysia` | [examples/elysia](https://github.com/h3js/srvx/tree/main/examples/elysia/) | `npx giget gh:h3js/srvx/examples/elysia srvx-elysia` | -| `express` | [examples/express](https://github.com/h3js/srvx/tree/main/examples/express/) | `npx giget gh:h3js/srvx/examples/express srvx-express` | -| `fastify` | [examples/fastify](https://github.com/h3js/srvx/tree/main/examples/fastify/) | `npx giget gh:h3js/srvx/examples/fastify srvx-fastify` | -| `h3` | [examples/h3](https://github.com/h3js/srvx/tree/main/examples/h3/) | `npx giget gh:h3js/srvx/examples/h3 srvx-h3` | -| `hello-world` | [examples/hello-world](https://github.com/h3js/srvx/tree/main/examples/hello-world/) | `npx giget gh:h3js/srvx/examples/hello-world srvx-hello-world` | -| `hono` | [examples/hono](https://github.com/h3js/srvx/tree/main/examples/hono/) | `npx giget gh:h3js/srvx/examples/hono srvx-hono` | -| `jsx` | [examples/jsx](https://github.com/h3js/srvx/tree/main/examples/jsx/) | `npx giget gh:h3js/srvx/examples/jsx srvx-jsx` | -| `node-handler` | [examples/node-handler](https://github.com/h3js/srvx/tree/main/examples/node-handler/) | `npx giget gh:h3js/srvx/examples/node-handler srvx-node-handler` | +| Example | Source | Try | +| --- | --- | --- | +| `aws-lambda` | [examples/aws-lambda](https://github.com/h3js/srvx/tree/main/examples/aws-lambda/) | `npx giget gh:h3js/srvx/examples/aws-lambda srvx-aws-lambda` | +| `elysia` | [examples/elysia](https://github.com/h3js/srvx/tree/main/examples/elysia/) | `npx giget gh:h3js/srvx/examples/elysia srvx-elysia` | +| `express` | [examples/express](https://github.com/h3js/srvx/tree/main/examples/express/) | `npx giget gh:h3js/srvx/examples/express srvx-express` | +| `fastify` | [examples/fastify](https://github.com/h3js/srvx/tree/main/examples/fastify/) | `npx giget gh:h3js/srvx/examples/fastify srvx-fastify` | +| `h3` | [examples/h3](https://github.com/h3js/srvx/tree/main/examples/h3/) | `npx giget gh:h3js/srvx/examples/h3 srvx-h3` | +| `hello-world` | [examples/hello-world](https://github.com/h3js/srvx/tree/main/examples/hello-world/) | `npx giget gh:h3js/srvx/examples/hello-world srvx-hello-world` | +| `hono` | [examples/hono](https://github.com/h3js/srvx/tree/main/examples/hono/) | `npx giget gh:h3js/srvx/examples/hono srvx-hono` | +| `jsx` | [examples/jsx](https://github.com/h3js/srvx/tree/main/examples/jsx/) | `npx giget gh:h3js/srvx/examples/jsx srvx-jsx` | +| `node-handler` | [examples/node-handler](https://github.com/h3js/srvx/tree/main/examples/node-handler/) | `npx giget gh:h3js/srvx/examples/node-handler srvx-node-handler` | | `service-worker` | [examples/service-worker](https://github.com/h3js/srvx/tree/main/examples/service-worker/) | `npx giget gh:h3js/srvx/examples/service-worker srvx-service-worker` | -| `streaming` | [examples/streaming](https://github.com/h3js/srvx/tree/main/examples/streaming/) | `npx giget gh:h3js/srvx/examples/streaming srvx-streaming` | -| `tracing` | [examples/tracing](https://github.com/h3js/srvx/tree/main/examples/tracing/) | `npx giget gh:h3js/srvx/examples/tracing srvx-tracing` | -| `websocket` | [examples/websocket](https://github.com/h3js/srvx/tree/main/examples/websocket/) | `npx giget gh:h3js/srvx/examples/websocket srvx-websocket` | +| `streaming` | [examples/streaming](https://github.com/h3js/srvx/tree/main/examples/streaming/) | `npx giget gh:h3js/srvx/examples/streaming srvx-streaming` | +| `tracing` | [examples/tracing](https://github.com/h3js/srvx/tree/main/examples/tracing/) | `npx giget gh:h3js/srvx/examples/tracing srvx-tracing` | +| `websocket` | [examples/websocket](https://github.com/h3js/srvx/tree/main/examples/websocket/) | `npx giget gh:h3js/srvx/examples/websocket srvx-websocket` | diff --git a/docs/1.guide/10.cli.md b/docs/1.guide/10.cli.md index ee4f31cc..23724ce3 100644 --- a/docs/1.guide/10.cli.md +++ b/docs/1.guide/10.cli.md @@ -60,38 +60,38 @@ $ echo '{"name":"foo"}' | srvx fetch -d @- /api # Body from stdin COMMON OPTIONS - --entry Server entry file to use - --dir Working directory for resolving entry file - -h, --help Show this help message - --version Show server and runtime versions + --entry Server entry file to use + --dir Working directory for resolving entry file + -h, --help Show this help message + --version Show server and runtime versions SERVE OPTIONS - -p, --port Port to listen on (default: 3000) - --host, --hostname Host to bind to (default: all interfaces) - -s, --static Serve static files from the specified directory (default: public) - --prod Run in production mode (no watch, no debug) - --import ES module to preload - --tls Enable TLS (HTTPS/HTTP2) - --cert TLS certificate file - --key TLS private key file + -p, --port Port to listen on (default: 3000) + --host, --hostname Host to bind to (default: all interfaces) + -s, --static Serve static files from the specified directory (default: public) + --prod Run in production mode (no watch, no debug) + --import ES module to preload + --tls Enable TLS (HTTPS/HTTP2) + --cert TLS certificate file + --key TLS private key file FETCH OPTIONS - -X, --method HTTP method (default: GET, or POST if body is provided; --request is a curl alias) - -H, --header
Add header (format: "Name: Value", can be used multiple times) - -d, --data Request body (use @- for stdin, @file for file) - --host Host for a schemeless URL/path (default: localhost) - --tls Use https for a schemeless URL/path - -v, --verbose Show request and response headers + -X, --method HTTP method (default: GET, or POST if body is provided; --request is a curl alias) + -H, --header
Add header (format: "Name: Value", can be used multiple times) + -d, --data Request body (use @- for stdin, @file for file) + --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 Default port to listen on + HOST Default host to bind to + NODE_ENV Set to production for production mode. ``` @@ -129,7 +129,7 @@ If `--static` is omitted, srvx serves files from a `public/` directory when one 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. -> [!NOTE] +> [!NOTE]] > `srvx/static` is **Node-API-only** β€” it uses `node:fs` and `node:zlib` internally, so despite the runtime-neutral name it only works on runtimes with Node.js compatibility (Node, Deno, Bun). For programmatic usage, import the [`serveStatic`](https://github.com/h3js/srvx/blob/main/src/static.ts) middleware: @@ -143,3 +143,123 @@ serve({ fetch: () => new Response("Not found", { status: 404 }), }); ``` + +## 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. + +```ts +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"); +} + +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. + +> [!NOTE] +> `srvx/loader` is **Node-API-only** — it uses `node:fs`, `node:http`, and `node:url` internally, so it works on runtimes with Node.js compatibility (Node, Deno, Bun) and not on edge runtimes. + +### 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 — 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, as the CLI does to reuse one server across both sides: + +```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`, returns a stub server from `listen()`, and defers the listen callback. The entry runs to completion believing it is listening. + +Pass `interceptHttpListen: false` to opt out. Note that this disables **both** interceptions — an entry that calls `serve()` will then really start listening on import. + +> [!NOTE] +> Interception patches a global (`http.Server.prototype.listen`), so concurrent `loadServerEntry` calls are queued and run one at a time. + +### 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: + +```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 — `srvx serve` forks the CLI and lets the runtime's own `--watch` handle reloads. + +### `LoadOptions` + +- **`entry`** — Path or `file://` URL of the server entry file. If omitted, the [default entries](#entry-resolution) 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. +- **`notFound`** — `true` when no entry file could be located. `fetch` and `module` are then `undefined`. +- **`nodeCompat`** — `true` 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](https://github.com/unjs/jiti), preloaded with the [`--import`](#usage) flag. diff --git a/docs/1.guide/4.middleware.md b/docs/1.guide/4.middleware.md index 8d0046c7..f3ea61e6 100644 --- a/docs/1.guide/4.middleware.md +++ b/docs/1.guide/4.middleware.md @@ -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** and excluded from the v1 [semver policy](/guide/node#semver-policy) β€” its channel names and event types may change in a minor release. The plugin is a no-op on runtimes without `node:diagnostics_channel` (e.g. Cloudflare Workers). diff --git a/docs/1.guide/5.options.md b/docs/1.guide/5.options.md index 74b88d90..cd5c86ba 100644 --- a/docs/1.guide/5.options.md +++ b/docs/1.guide/5.options.md @@ -79,9 +79,6 @@ await server.serve(); await server.ready(); ``` -> [!NOTE] -> `manual` is meaningless on module-worker style runtimes (Cloudflare module syntax, Bunny), where the runtime drives the request lifecycle and there is no listening step to defer. - ### `middleware` An array of [middleware](/guide/middleware) handlers to run before the main `fetch` handler. @@ -342,35 +339,4 @@ srvx aims for identical behavior across runtimes, but a few options depend on ca | `manual` | Meaningless on module-worker runtimes (Cloudflare module syntax, Bunny) β€” there is no listening step to defer. | | `gracefulShutdown` | Supported on Node, Deno and Bun. No-op on Cloudflare, Bunny and service-worker runtimes. | | Cloudflare `env` bindings | `request.runtime.cloudflare.env` is only populated in **module-worker syntax**. In service-worker syntax (the global `fetch` listener) bindings are unavailable. | -| **`upgrade` / WebSocket** | srvx does **not** handle HTTP `upgrade` requests on **any** runtime β€” WebSocket upgrades never reach your `fetch` handler. Use [crossws](https://crossws.h3.dev/) for WebSocket support. | - -### Deno version - -srvx targets **Deno 2.x** (CI runs against Deno 2.7.x). - -> [!IMPORTANT] -> Under the Bunny runtime, a root `srvx` import resolves the `deno` export condition (Bunny is Deno-based) and would give you the Deno adapter. Always import the Bunny adapter explicitly via `srvx/bunny`. - -## Public Subpaths - -In addition to the main `srvx` entry and the runtime adapters, srvx exposes several public subpaths: - -| Subpath | Exports | Use when | -| --------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `srvx/static` | `serveStatic` middleware | Serving static files. **Node-API-only** (uses `node:fs` / `node:zlib`) despite the neutral name β€” works only on runtimes with Node compatibility. | -| `srvx/log` | `log()` middleware | Adding request logging (used by the CLI). | -| `srvx/loader` | `loadServerEntry`, related types | Resolving and loading a server entry file (as the CLI does). | -| `srvx/cli` | `main`, `cliFetch`, CLI types | Building a custom CLI on top of srvx. | -| `srvx/generic` | `serve`, `FastURL`, `FastResponse` | A web-standard (`fetch`/`Response`) runtime with no dedicated adapter. | -| `srvx/service-worker` | `serve`, `FastURL`, `FastResponse` | Running inside a browser/service-worker environment. | - -## TypeScript Types - -Runtime-specific option and context types are typed against each runtime's own type packages, which srvx does **not** bundle. To get full typing for `options.bun`, `options.deno`, `request.runtime.cloudflare`, `request.runtime.awsLambda` (and similar), install the relevant `devDependencies` in your project: - -- **Bun** (`options.bun`): [`@types/bun`](https://www.npmjs.com/package/@types/bun) (or `bun-types`) -- **Deno** (`options.deno`, `request.runtime.deno`): [`@types/deno`](https://www.npmjs.com/package/@types/deno) -- **Cloudflare** (`request.runtime.cloudflare`): [`@cloudflare/workers-types`](https://www.npmjs.com/package/@cloudflare/workers-types) -- **AWS Lambda** (`request.runtime.awsLambda`): [`@types/aws-lambda`](https://www.npmjs.com/package/@types/aws-lambda) - -Without these, the corresponding fields fall back to `any` (with `skipLibCheck`) or raise type errors (without it). +| **`upgrade` / WebSocket** | srvx does **not** handle HTTP `upgrade` requests by default. Use [crossws](https://crossws.h3.dev/guide) for WebSocket support. | diff --git a/src/cli/usage.ts b/src/cli/usage.ts index c4ad7565..1c52ebc4 100644 --- a/src/cli/usage.ts +++ b/src/cli/usage.ts @@ -37,38 +37,38 @@ ${c.gray("$")} echo '{"name":"foo"}' | ${c.cyan(command)} fetch -d @- /api ${c.g ${c.bold("COMMON OPTIONS")} - ${c.green("--entry")} ${c.yellow("")} Server entry file to use - ${c.green("--dir")} ${c.yellow("")} Working directory for resolving entry file - ${c.green("-h, --help")} Show this help message - ${c.green("--version")} Show server and runtime versions + ${c.green("--entry")} ${c.yellow("")} Server entry file to use + ${c.green("--dir")} ${c.yellow("")} Working directory for resolving entry file + ${c.green("-h, --help")} Show this help message + ${c.green("--version")} Show server and runtime versions ${c.bold("SERVE OPTIONS")} - ${c.green("-p, --port")} ${c.yellow("")} Port to listen on (default: ${c.yellow("3000")}) - ${c.green("--host, --hostname")} ${c.yellow("")} Host to bind to (default: all interfaces) - ${c.green("-s, --static")} ${c.yellow("")} Serve static files from the specified directory (default: ${c.yellow("public")}) - ${c.green("--prod")} Run in production mode (no watch, no debug) - ${c.green("--import")} ${c.yellow("")} ES module to preload - ${c.green("--tls")} Enable TLS (HTTPS/HTTP2) - ${c.green("--cert")} ${c.yellow("")} TLS certificate file - ${c.green("--key")} ${c.yellow("")} TLS private key file + ${c.green("-p, --port")} ${c.yellow("")} Port to listen on (default: ${c.yellow("3000")}) + ${c.green("--host, --hostname")} ${c.yellow("")} Host to bind to (default: all interfaces) + ${c.green("-s, --static")} ${c.yellow("")} Serve static files from the specified directory (default: ${c.yellow("public")}) + ${c.green("--prod")} Run in production mode (no watch, no debug) + ${c.green("--import")} ${c.yellow("")} ES module to preload + ${c.green("--tls")} Enable TLS (HTTPS/HTTP2) + ${c.green("--cert")} ${c.yellow("")} TLS certificate file + ${c.green("--key")} ${c.yellow("")} TLS private key file ${c.bold("FETCH OPTIONS")} - ${c.green("-X, --method")} ${c.yellow("")} HTTP method (default: ${c.yellow("GET")}, or ${c.yellow("POST")} if body is provided; ${c.green("--request")} is a curl alias) - ${c.green("-H, --header")} ${c.yellow("
")} Add header (format: "Name: Value", can be used multiple times) - ${c.green("-d, --data")} ${c.yellow("")} Request body (use ${c.yellow("@-")} for stdin, ${c.yellow("@file")} for file) - ${c.green("--host")} ${c.yellow("")} Host for a schemeless URL/path (default: ${c.yellow("localhost")}) - ${c.green("--tls")} Use ${c.yellow("https")} for a schemeless URL/path - ${c.green("-v, --verbose")} Show request and response headers + ${c.green("-X, --method")} ${c.yellow("")} HTTP method (default: ${c.yellow("GET")}, or ${c.yellow("POST")} if body is provided; ${c.green("--request")} is a curl alias) + ${c.green("-H, --header")} ${c.yellow("
")} Add header (format: "Name: Value", can be used multiple times) + ${c.green("-d, --data")} ${c.yellow("")} Request body (use ${c.yellow("@-")} for stdin, ${c.yellow("@file")} for file) + ${c.green("--host")} ${c.yellow("")} Host for a schemeless URL/path (default: ${c.yellow("localhost")}) + ${c.green("--tls")} Use ${c.yellow("https")} for a schemeless URL/path + ${c.green("-v, --verbose")} Show request and response headers Exits with code ${c.yellow("22")} on a non-2xx response (like ${c.cyan("curl --fail")}). ${c.bold("ENVIRONMENT")} - ${c.green("PORT")} Default port to listen on - ${c.green("HOST")} Default host to bind to - ${c.green("NODE_ENV")} Set to ${c.yellow("production")} for production mode. + ${c.green("PORT")} Default port to listen on + ${c.green("HOST")} Default host to bind to + ${c.green("NODE_ENV")} Set to ${c.yellow("production")} for production mode. ${mainOpts.usage?.docs ? `➀ ${c.url("Documentation", mainOpts.usage.docs)}` : ""} ${mainOpts.usage?.issues ? `➀ ${c.url("Report issues", mainOpts.usage.issues)}` : ""} From b0ed3673166331240ffb9cea2868510f37c4c2a6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:41:44 +0000 Subject: [PATCH 03/10] chore: apply automated updates --- docs/1.guide/1.index.md | 28 ++++++++++++++-------------- docs/1.guide/5.options.md | 2 +- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/1.guide/1.index.md b/docs/1.guide/1.index.md index a3b4f96c..d691f4b1 100644 --- a/docs/1.guide/1.index.md +++ b/docs/1.guide/1.index.md @@ -87,20 +87,20 @@ bun run server.mjs -| Example | Source | Try | -| --- | --- | --- | -| `aws-lambda` | [examples/aws-lambda](https://github.com/h3js/srvx/tree/main/examples/aws-lambda/) | `npx giget gh:h3js/srvx/examples/aws-lambda srvx-aws-lambda` | -| `elysia` | [examples/elysia](https://github.com/h3js/srvx/tree/main/examples/elysia/) | `npx giget gh:h3js/srvx/examples/elysia srvx-elysia` | -| `express` | [examples/express](https://github.com/h3js/srvx/tree/main/examples/express/) | `npx giget gh:h3js/srvx/examples/express srvx-express` | -| `fastify` | [examples/fastify](https://github.com/h3js/srvx/tree/main/examples/fastify/) | `npx giget gh:h3js/srvx/examples/fastify srvx-fastify` | -| `h3` | [examples/h3](https://github.com/h3js/srvx/tree/main/examples/h3/) | `npx giget gh:h3js/srvx/examples/h3 srvx-h3` | -| `hello-world` | [examples/hello-world](https://github.com/h3js/srvx/tree/main/examples/hello-world/) | `npx giget gh:h3js/srvx/examples/hello-world srvx-hello-world` | -| `hono` | [examples/hono](https://github.com/h3js/srvx/tree/main/examples/hono/) | `npx giget gh:h3js/srvx/examples/hono srvx-hono` | -| `jsx` | [examples/jsx](https://github.com/h3js/srvx/tree/main/examples/jsx/) | `npx giget gh:h3js/srvx/examples/jsx srvx-jsx` | -| `node-handler` | [examples/node-handler](https://github.com/h3js/srvx/tree/main/examples/node-handler/) | `npx giget gh:h3js/srvx/examples/node-handler srvx-node-handler` | +| Example | Source | Try | +| ---------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | +| `aws-lambda` | [examples/aws-lambda](https://github.com/h3js/srvx/tree/main/examples/aws-lambda/) | `npx giget gh:h3js/srvx/examples/aws-lambda srvx-aws-lambda` | +| `elysia` | [examples/elysia](https://github.com/h3js/srvx/tree/main/examples/elysia/) | `npx giget gh:h3js/srvx/examples/elysia srvx-elysia` | +| `express` | [examples/express](https://github.com/h3js/srvx/tree/main/examples/express/) | `npx giget gh:h3js/srvx/examples/express srvx-express` | +| `fastify` | [examples/fastify](https://github.com/h3js/srvx/tree/main/examples/fastify/) | `npx giget gh:h3js/srvx/examples/fastify srvx-fastify` | +| `h3` | [examples/h3](https://github.com/h3js/srvx/tree/main/examples/h3/) | `npx giget gh:h3js/srvx/examples/h3 srvx-h3` | +| `hello-world` | [examples/hello-world](https://github.com/h3js/srvx/tree/main/examples/hello-world/) | `npx giget gh:h3js/srvx/examples/hello-world srvx-hello-world` | +| `hono` | [examples/hono](https://github.com/h3js/srvx/tree/main/examples/hono/) | `npx giget gh:h3js/srvx/examples/hono srvx-hono` | +| `jsx` | [examples/jsx](https://github.com/h3js/srvx/tree/main/examples/jsx/) | `npx giget gh:h3js/srvx/examples/jsx srvx-jsx` | +| `node-handler` | [examples/node-handler](https://github.com/h3js/srvx/tree/main/examples/node-handler/) | `npx giget gh:h3js/srvx/examples/node-handler srvx-node-handler` | | `service-worker` | [examples/service-worker](https://github.com/h3js/srvx/tree/main/examples/service-worker/) | `npx giget gh:h3js/srvx/examples/service-worker srvx-service-worker` | -| `streaming` | [examples/streaming](https://github.com/h3js/srvx/tree/main/examples/streaming/) | `npx giget gh:h3js/srvx/examples/streaming srvx-streaming` | -| `tracing` | [examples/tracing](https://github.com/h3js/srvx/tree/main/examples/tracing/) | `npx giget gh:h3js/srvx/examples/tracing srvx-tracing` | -| `websocket` | [examples/websocket](https://github.com/h3js/srvx/tree/main/examples/websocket/) | `npx giget gh:h3js/srvx/examples/websocket srvx-websocket` | +| `streaming` | [examples/streaming](https://github.com/h3js/srvx/tree/main/examples/streaming/) | `npx giget gh:h3js/srvx/examples/streaming srvx-streaming` | +| `tracing` | [examples/tracing](https://github.com/h3js/srvx/tree/main/examples/tracing/) | `npx giget gh:h3js/srvx/examples/tracing srvx-tracing` | +| `websocket` | [examples/websocket](https://github.com/h3js/srvx/tree/main/examples/websocket/) | `npx giget gh:h3js/srvx/examples/websocket srvx-websocket` | diff --git a/docs/1.guide/5.options.md b/docs/1.guide/5.options.md index cd5c86ba..5867612a 100644 --- a/docs/1.guide/5.options.md +++ b/docs/1.guide/5.options.md @@ -339,4 +339,4 @@ srvx aims for identical behavior across runtimes, but a few options depend on ca | `manual` | Meaningless on module-worker runtimes (Cloudflare module syntax, Bunny) β€” there is no listening step to defer. | | `gracefulShutdown` | Supported on Node, Deno and Bun. No-op on Cloudflare, Bunny and service-worker runtimes. | | Cloudflare `env` bindings | `request.runtime.cloudflare.env` is only populated in **module-worker syntax**. In service-worker syntax (the global `fetch` listener) bindings are unavailable. | -| **`upgrade` / WebSocket** | srvx does **not** handle HTTP `upgrade` requests by default. Use [crossws](https://crossws.h3.dev/guide) for WebSocket support. | +| **`upgrade` / WebSocket** | srvx does **not** handle HTTP `upgrade` requests by default. Use [crossws](https://crossws.h3.dev/guide) for WebSocket support. | From fa35a191e7a2bef3807fd5319e7c29cb43fed58a Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 16 Jul 2026 14:42:22 +0000 Subject: [PATCH 04/10] up --- docs/1.guide/10.cli.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/1.guide/10.cli.md b/docs/1.guide/10.cli.md index 23724ce3..0ab255b9 100644 --- a/docs/1.guide/10.cli.md +++ b/docs/1.guide/10.cli.md @@ -109,9 +109,6 @@ The port and host are resolved with the following precedence (highest first): In **fetch mode**, `srvx fetch` exits with code **`22`** for any non-2xx response, and `0` for a 2xx response. -> [!IMPORTANT] -> This diverges from `curl`, which exits `0` regardless of the HTTP status unless you pass `--fail`. With `srvx fetch` the non-2xx exit is the **default**, so no extra flag is needed to make a failing request fail your script. - ## Runtime notes > [!NOTE] From d28832a065ac985beb2d614dffe0a4f0c69826cd Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 16 Jul 2026 14:43:41 +0000 Subject: [PATCH 05/10] up --- docs/1.guide/10.cli.md | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/docs/1.guide/10.cli.md b/docs/1.guide/10.cli.md index 0ab255b9..014ccd29 100644 --- a/docs/1.guide/10.cli.md +++ b/docs/1.guide/10.cli.md @@ -116,7 +116,7 @@ In **fetch mode**, `srvx fetch` exits with code **`22`** for any non-2xx respons ## 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 — point `--static` at any folder: +The CLI can serve a directory of static files. No server entry is required — point `--static` at any folder: ```bash npx srvx --static ./dist @@ -126,21 +126,6 @@ If `--static` is omitted, srvx serves files from a `public/` directory when one 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. -> [!NOTE]] -> `srvx/static` is **Node-API-only** β€” it uses `node:fs` and `node:zlib` internally, so despite the runtime-neutral name it only works on runtimes with Node.js compatibility (Node, Deno, Bun). - -For programmatic usage, import the [`serveStatic`](https://github.com/h3js/srvx/blob/main/src/static.ts) middleware: - -```ts [server.ts] -import { serve } from "srvx"; -import { serveStatic } from "srvx/static"; - -serve({ - middleware: [serveStatic({ dir: "public" })], - fetch: () => new Response("Not found", { status: 404 }), -}); -``` - ## 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. From 208a29b67fe1d9d73d32b6863057bc25fbd83fb9 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 16 Jul 2026 14:44:52 +0000 Subject: [PATCH 06/10] up --- docs/1.guide/10.cli.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/1.guide/10.cli.md b/docs/1.guide/10.cli.md index 014ccd29..039d69aa 100644 --- a/docs/1.guide/10.cli.md +++ b/docs/1.guide/10.cli.md @@ -122,7 +122,9 @@ The CLI can serve a directory of static files. No server entry is required &mdas 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. @@ -177,7 +179,7 @@ If none match and `nodeCompat` is not disabled, a legacy Node.js `(req, res)` ha 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, as the CLI does to reuse one server across both sides: +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: ```ts let server: Server | undefined; @@ -194,12 +196,12 @@ 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`, returns a stub server from `listen()`, and defers the listen callback. The entry runs to completion believing it is listening. +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. > [!NOTE] -> Interception patches a global (`http.Server.prototype.listen`), so concurrent `loadServerEntry` calls are queued and run one at a time. +> Entries are intercepted one at a time — concurrent `loadServerEntry` calls are queued rather than run in parallel. ### Reloading an entry @@ -217,7 +219,7 @@ 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 — `srvx serve` forks the CLI and lets the runtime's own `--watch` handle reloads. +> 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` From 651d516cac58d268f419323b897230fc9f0cb2a5 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 16 Jul 2026 14:46:44 +0000 Subject: [PATCH 07/10] up --- docs/1.guide/4.middleware.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/1.guide/4.middleware.md b/docs/1.guide/4.middleware.md index f3ea61e6..fb00ccb7 100644 --- a/docs/1.guide/4.middleware.md +++ b/docs/1.guide/4.middleware.md @@ -131,4 +131,4 @@ Each event carries `{ server, request }`, plus `{ middleware: { index, handler } 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** and excluded from the v1 [semver policy](/guide/node#semver-policy) β€” its channel names and event types may change in a minor release. The plugin is a no-op on runtimes without `node:diagnostics_channel` (e.g. Cloudflare Workers). +> `srvx/tracing` is **experimental**. From 649744f2c096e48aefc114de55d7d8566fa387ce Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 16 Jul 2026 14:48:00 +0000 Subject: [PATCH 08/10] up --- docs/1.guide/8.node.md | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/docs/1.guide/8.node.md b/docs/1.guide/8.node.md index c9882080..9b386e0a 100644 --- a/docs/1.guide/8.node.md +++ b/docs/1.guide/8.node.md @@ -82,9 +82,6 @@ For example, calling `new Request(req)` may throw `Cannot read private member #s To avoid these issues, you can use `req.clone()` or `req._request` to access an undici instance of request. Alongside it, `req._url` exposes the already-parsed `URL` for the request. -> [!NOTE] -> `request._request` and `request._url` are **supported, public API** β€” the leading underscore is legacy naming, not a privacy marker. Their shape is frozen under the v1 [semver policy](#semver-policy), so you can rely on them. - Alternatively, you can patch the global `Request` class in Node.js. ```js @@ -150,22 +147,6 @@ const res = await fetchNodeHandler(app, new Request("http://localhost/")); console.log(res.status, res.statusText, await res.text()); ``` -> [!NOTE] -> `toFetchHandler` and `fetchNodeHandler` are **stable** and frozen under the v1 [semver policy](#semver-policy). By contrast, the tracing plugin (`srvx/tracing`) is **experimental** and excluded from the v1 guarantee β€” its channel names and event types may change in a minor release. - -## Semver policy - -Starting with v1, srvx follows [semantic versioning](https://semver.org/). The following are covered by the stability guarantee (no breaking changes in a minor or patch release): - -- The `serve()` options and the `Server` / `ServerRequest` interfaces. -- `request._request` and `request._url` (supported API β€” see [above](#noderequest)). -- The `toFetchHandler` and `fetchNodeHandler` utilities from `srvx/node`. - -The following are **explicitly excluded** from the v1 guarantee: - -- **`srvx/tracing`** β€” experimental. Channel names and event types may change in a minor release. -- **Error `code`s** β€” only `ERR_BODY_TOO_LARGE` (thrown when `maxRequestBodySize` is exceeded) is a guaranteed, stable `code`. Other thrown errors carry no guaranteed `code`, and adding new codes later is considered a non-breaking change. - [Node.js]: https://nodejs.org/ [Deno]: https://deno.com/ [Bun]: https://bun.sh/ From 2e9d94acd2c84f38bb9dd433213d384c32c96d09 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 16 Jul 2026 14:52:40 +0000 Subject: [PATCH 09/10] up --- docs/1.guide/10.cli.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/1.guide/10.cli.md b/docs/1.guide/10.cli.md index 039d69aa..00f7007a 100644 --- a/docs/1.guide/10.cli.md +++ b/docs/1.guide/10.cli.md @@ -142,7 +142,11 @@ if (loaded.notFound) { throw new Error("No server entry found"); } -serve({ fetch: loaded.fetch! }); +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. From ff868173be25a9a887264239ced5b9d0ae7059d5 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Thu, 16 Jul 2026 15:00:02 +0000 Subject: [PATCH 10/10] up --- docs/1.guide/10.cli.md | 3 --- docs/1.guide/3.server.md | 3 --- 2 files changed, 6 deletions(-) diff --git a/docs/1.guide/10.cli.md b/docs/1.guide/10.cli.md index 00f7007a..47737e70 100644 --- a/docs/1.guide/10.cli.md +++ b/docs/1.guide/10.cli.md @@ -151,9 +151,6 @@ 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. -> [!NOTE] -> `srvx/loader` is **Node-API-only** — it uses `node:fs`, `node:http`, and `node:url` internally, so it works on runtimes with Node.js compatibility (Node, Deno, Bun) and not on edge runtimes. - ### Entry resolution When `entry` is set, it is resolved against `dir` (or the current working directory). A `file://` URL is used as-is. diff --git a/docs/1.guide/3.server.md b/docs/1.guide/3.server.md index 942acec4..5e8ee16c 100644 --- a/docs/1.guide/3.server.md +++ b/docs/1.guide/3.server.md @@ -88,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.