# Server orchestration and local proxies

Set up zhost with explicit lifecycle, typed public APIs and deployment boundaries.


## Installation and integration path

```sh
npm install zhost@0.0.4
```

This package has its own public API. Installing it does not create an automatic runtime service key in `zerux.config.js`. Use the framework integration described below or initialize it in the tool/application that owns its lifetime.

## Worked TypeScript setup

```ts
import { startServer } from 'zhost';
type ServerSetup = Parameters<typeof startServer>[0];
type StartedServer = Awaited<ReturnType<typeof startServer>>;
// Construct ServerSetup using the real app request handler and explicit development policy.
```

JavaScript can use the same runtime APIs without type-only statements. Enable JSDoc checking if editor validation is desired; a transpilation step alone does not typecheck the input.

## Usage and configuration

Zhost coordinates server startup, local proxy routing, development client connections and related lifecycle operations. The main Zerux server command supplies its application runtime; a standalone host must supply its own handler and shutdown policy. It is not a general static-export pipeline.

Portless aliases, proxy state, supervised processes and protocol gateways have separate focused contracts. A configured alias does not prove public DNS/TLS or authenticate a visitor. Shared state directories must be protected from untrusted writers, and process markers/health checks are not cryptographic ownership proofs.

## Public types

```ts
import { startServer } from 'zhost';
type Inputs = Parameters<typeof startServer>;
type Result = Awaited<ReturnType<typeof startServer>>;
```

Use installed public declarations and validate untrusted runtime values separately. Avoid deep source imports; the manifest reference below records the actual exported entry points for this version. Generic helpers may preserve a more useful concrete type at the call site than a broad ReturnType alias.

## Failures, ownership and production use

Treat established WebSocket lifetimes separately from upstream handshake timeouts. Validate forwarded authority and do not trust arbitrary forwarding headers from an external client. The local development proxy has explicit limitations; production hosting needs its own authentication/network boundary and operational validation. Test stale process markers, interrupted state updates, route conflicts, child exit and shutdown, rather than force-killing an assumed PID.

## Verify the integration

Run the real owning application/tool with a normal input and a failure input. Inspect exit status or typed results, then verify cleanup/restart and the installed production entry point. A successful docs build does not exercise child processes, browser APIs, sockets or filesystem permissions on every supported operating system.

## Option reference

### SupervisorOptions

| Option              | Accepted TypeScript value         | Required | Meaning                                                                          |
| ------------------- | --------------------------------- | -------- | -------------------------------------------------------------------------------- |
| `env`               | `NodeJS.ProcessEnv`               | No       | Passed using this declared shape; omission is allowed only when marked optional. |
| `cwd`               | `string`                          | No       | Passed using this declared shape; omission is allowed only when marked optional. |
| `port`              | `number`                          | No       | Passed using this declared shape; omission is allowed only when marked optional. |
| `readyTimeoutMs`    | `number`                          | No       | Readiness deadline for a TCP listener, milliseconds; ignored if port is unset.   |
| `shutdownTimeoutMs` | `number`                          | No       | Deadline to give the child for graceful termination before SIGKILL.              |
| `onExit`            | `(result: ProcessExit) => void`   | No       | Called exactly once when the child exits or fails to spawn.                      |
| `onSpawn`           | `(child: ChildProcess) => void`   | No       | Called immediately after spawn, before readiness, to persist child identity.     |
| `stdio`             | `'inherit' \| 'ignore' \| 'pipe'` | No       | Passed using this declared shape; omission is allowed only when marked optional. |
| `forwardSignals`    | `boolean`                         | No       | Install SIGINT/SIGTERM forwarding from this process (default true).              |
| `health`            | `HealthCheckOptions`              | No       | Use an HTTP endpoint for readiness instead of only testing an open TCP port.     |

### ProxyServerOptions

| Option            | Accepted TypeScript value                                                                                                                                                                                            | Required | Meaning                                                                                                                           |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `cache`           | `import("./proxy-cache.js").ProxyCacheOptions`                                                                                                                                                                       | No       | Opt-in reverse-proxy cache; caches only explicit public GET representations.                                                      |
| `compression`     | `import("./proxy-compression.js").ProxyCompressionOptions`                                                                                                                                                           | No       | Enable transparent streaming compression of eligible unencoded upstream responses.                                                |
| `protocols`       | `import("../protocols/gateway.js").ProtocolGateway`                                                                                                                                                                  | No       | Optional application protocols registered at startup.                                                                             |
| `http3Terminator` | `import("../protocols/transport.js").Http3Terminator`                                                                                                                                                                | No       | Advertise external HTTP/3+QUIC termination only if actually configured.                                                           |
| `limits`          | `import('./proxy-admission.js').ProxyAdmissionOptions`                                                                                                                                                               | No       | Optional admission limits for HTTP forwarding (WebSocket upgrades are separate).                                                  |
| `getRoutes`       | `() => RouteInfo[]`                                                                                                                                                                                                  | Yes      | Called on each request to get the current route table.                                                                            |
| `backendHealth`   | `import("./backend-pool.js").BackendHealthOptions`                                                                                                                                                                   | No       | Optional active checks and passive failure isolation for backend pools.                                                           |
| `proxyPort`       | `number`                                                                                                                                                                                                             | Yes      | The port the proxy is listening on (used to build correct URLs).                                                                  |
| `tld`             | `string`                                                                                                                                                                                                             | No       | TLD suffix used for hostnames (default: "localhost").                                                                             |
| `onError`         | `(message: string) => void`                                                                                                                                                                                          | No       | Optional error logger; defaults to console.error.                                                                                 |
| `allowPlainHttp`  | `boolean`                                                                                                                                                                                                            | No       | Explicit compatibility switch for mixed HTTP/TLS on the same port. Disabled by default to prevent accidental plaintext transport. |
| `tls`             | `{ cert: Buffer; key: Buffer; /** SNI callback for per-hostname certificate selection. */ SNICallback?: ( servername: string, cb: (err: Error \| null, ctx?: import("node:tls").SecureContext) => void ) => void; }` | No       | When provided, enables HTTP/2 over TLS (HTTPS).                                                                                   |

### HealthCheckOptions

| Option      | Accepted TypeScript value | Required | Meaning                                                                          |
| ----------- | ------------------------- | -------- | -------------------------------------------------------------------------------- |
| `path`      | `string`                  | No       | A relative, origin-form HTTP path; omit for a TCP connection probe.              |
| `timeoutMs` | `number`                  | No       | Per-probe timeout in milliseconds.                                               |
| `minStatus` | `number`                  | No       | HTTP status range considered healthy, inclusive. Defaults to 200–299.            |
| `maxStatus` | `number`                  | No       | Passed using this declared shape; omission is allowed only when marked optional. |

### ManagedAppOptions

| Option               | Accepted TypeScript value                                                               | Required | Meaning                                                                           |
| -------------------- | --------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
| `restartPolicy`      | `RestartPolicy`                                                                         | No       | Passed using this declared shape; omission is allowed only when marked optional.  |
| `maxRestarts`        | `number`                                                                                | No       | Passed using this declared shape; omission is allowed only when marked optional.  |
| `restartWindowMs`    | `number`                                                                                | No       | Passed using this declared shape; omission is allowed only when marked optional.  |
| `restartDelayMs`     | `number`                                                                                | No       | Passed using this declared shape; omission is allowed only when marked optional.  |
| `maxRestartDelayMs`  | `number`                                                                                | No       | Passed using this declared shape; omission is allowed only when marked optional.  |
| `healthIntervalMs`   | `number`                                                                                | No       | Passed using this declared shape; omission is allowed only when marked optional.  |
| `unhealthyThreshold` | `number`                                                                                | No       | Passed using this declared shape; omission is allowed only when marked optional.  |
| `onReady`            | `(process: SupervisedProcess) => void \| (() => void) \| Promise<void \| (() => void)>` | No       | Publish the route after readiness; return its cleanup closure.                    |
| `onRestart`          | `(attempt: number, delayMs: number, reason: ProcessExit) => void`                       | No       | Application-supplied callback or adapter; see the setup and lifecycle discussion. |
| `onStatus`           | `StatusListener`                                                                        | No       | Passed using this declared shape; omission is allowed only when marked optional.  |

### BackendHealthOptions

| Option              | Accepted TypeScript value | Required | Meaning                                                                                       |
| ------------------- | ------------------------- | -------- | --------------------------------------------------------------------------------------------- |
| `intervalMs`        | `number`                  | No       | Enables background probes; omit to use passive upstream errors only.                          |
| `failureThreshold`  | `number`                  | No       | Number of successive active probe failures before isolation (default 2).                      |
| `cooldownMs`        | `number`                  | No       | Passive failures are isolated for this long (default 5000ms).                                 |
| `probe`             | `HealthCheckOptions`      | No       | Optional HTTP health endpoint; otherwise probes TCP on loopback.                              |
| `stateDir`          | `string`                  | No       | Optional private state directory for restoring recent failure isolation after proxy restarts. |
| `stateMaxAgeMs`     | `number`                  | No       | Reject stored circuit-breaker state older than this (default 60 seconds).                     |
| `persistDebounceMs` | `number`                  | No       | Coalesce checkpoint writes; never write synchronously per HTTP request (default 250ms).       |

### HandoverOptions

| Option       | Accepted TypeScript value | Required | Meaning                                                                          |
| ------------ | ------------------------- | -------- | -------------------------------------------------------------------------------- |
| `health`     | `HealthCheckOptions`      | No       | Passed using this declared shape; omission is allowed only when marked optional. |
| `requireAll` | `boolean`                 | No       | Require every target to pass the probe (default true).                           |

### RollingAppOptions

| Option                   | Accepted TypeScript value                     | Required | Meaning                                                                           |
| ------------------------ | --------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
| `onSpawn`                | `(child: ChildProcess, port: number) => void` | No       | Record each detached generation as soon as it spawns (before readiness).          |
| `onLease`                | `(leaseId: string) => void`                   | No       | Persist the initial route lease before returning a published controller.          |
| `store`                  | `RouteStore`                                  | Yes      | Passed using this declared shape; omission is allowed only when marked optional.  |
| `hostname`               | `string`                                      | Yes      | Passed using this declared shape; omission is allowed only when marked optional.  |
| `ports`                  | `readonly number[]`                           | Yes      | Two or more private, distinct loopback ports. Each child receives PORT.           |
| `connections`            | `Pick<BackendConnections, 'waitForIdle'>`     | Yes      | Backend connection tracker of the actual proxy serving this hostname.             |
| `drainTimeoutMs`         | `number`                                      | No       | Maximum time to let old HTTP requests and WebSockets finish after cutover.        |
| `preCutoverChecks`       | `number`                                      | No       | Number of independent health checks required before publishing a replacement.     |
| `verificationChecks`     | `number`                                      | No       | Number of post-cutover health probes before retiring the old process.             |
| `verificationIntervalMs` | `number`                                      | No       | Passed using this declared shape; omission is allowed only when marked optional.  |
| `force`                  | `boolean`                                     | No       | Explicitly replace an existing registration; disabled by default.                 |
| `onStatus`               | `(status: Readonly<RollingStatus>) => void`   | No       | Application-supplied callback or adapter; see the setup and lifecycle discussion. |

### ReconciliationOptions

| Option       | Accepted TypeScript value | Required | Meaning                                                                          |
| ------------ | ------------------------- | -------- | -------------------------------------------------------------------------------- |
| `dryRun`     | `boolean`                 | No       | Passed using this declared shape; omission is allowed only when marked optional. |
| `waitMs`     | `number`                  | No       | Passed using this declared shape; omission is allowed only when marked optional. |
| `maxRecords` | `number`                  | No       | Passed using this declared shape; omission is allowed only when marked optional. |

### InstanceFleetOptions

| Option                  | Accepted TypeScript value                     | Required | Meaning                                                                           |
| ----------------------- | --------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
| `store`                 | `RouteStore`                                  | Yes      | Passed using this declared shape; omission is allowed only when marked optional.  |
| `hostname`              | `string`                                      | Yes      | Passed using this declared shape; omission is allowed only when marked optional.  |
| `ports`                 | `readonly number[]`                           | Yes      | At least replicas + 1 distinct ports, to allow a ready-before-cutover spare.      |
| `replicas`              | `number`                                      | Yes      | 2–16 processes; at least one spare port is required.                              |
| `connections`           | `Pick<BackendConnections, 'waitForIdle'>`     | Yes      | Passed using this declared shape; omission is allowed only when marked optional.  |
| `healthIntervalMs`      | `number`                                      | No       | Passed using this declared shape; omission is allowed only when marked optional.  |
| `unhealthyThreshold`    | `number`                                      | No       | Passed using this declared shape; omission is allowed only when marked optional.  |
| `maxReplacements`       | `number`                                      | No       | Maximum attempted replacements during restartWindowMs (0 disables recovery).      |
| `restartWindowMs`       | `number`                                      | No       | Passed using this declared shape; omission is allowed only when marked optional.  |
| `replacementDelayMs`    | `number`                                      | No       | Passed using this declared shape; omission is allowed only when marked optional.  |
| `maxReplacementDelayMs` | `number`                                      | No       | Passed using this declared shape; omission is allowed only when marked optional.  |
| `drainTimeoutMs`        | `number`                                      | No       | Passed using this declared shape; omission is allowed only when marked optional.  |
| `force`                 | `boolean`                                     | No       | Permit explicit replacement of an existing route owned by someone else.           |
| `onSpawn`               | `(child: ChildProcess, port: number) => void` | No       | Persist each generation immediately after spawn, before health readiness.         |
| `onLease`               | `(leaseId: string) => void`                   | No       | Application-supplied callback or adapter; see the setup and lifecycle discussion. |
| `onStatus`              | `(status: Readonly<FleetStatus>) => void`     | No       | Application-supplied callback or adapter; see the setup and lifecycle discussion. |
| `restoredAttempts`      | `readonly number[]`                           | No       | Retry timestamps restored from a private checkpoint; bounded by current policy.   |
| `onBudgetAttempt`       | `(attempts: readonly number[]) => void`       | No       | Synchronous durable checkpoint callback, invoked before every replacement.        |

### AutoscaleConfig

| Option               | Accepted TypeScript value | Required | Meaning                                                                          |
| -------------------- | ------------------------- | -------- | -------------------------------------------------------------------------------- |
| `metric`             | `AutoscaleMetric`         | No       | rps: incoming requests per replica; cpu: one-core CPU %; memory: mean RSS MiB.   |
| `scaleUpThreshold`   | `number`                  | No       | Required for CPU and memory modes. Overrides RPS thresholds.                     |
| `scaleDownThreshold` | `number`                  | No       | Passed using this declared shape; omission is allowed only when marked optional. |
| `maxErrorRate`       | `number`                  | No       | Optional safeguards: only allow downscaling with valid healthy proxy telemetry.  |
| `maxP95LatencyMs`    | `number`                  | No       | Passed using this declared shape; omission is allowed only when marked optional. |
| `enabled`            | `boolean`                 | Yes      | Passed using this declared shape; omission is allowed only when marked optional. |
| `minReplicas`        | `number`                  | Yes      | Passed using this declared shape; omission is allowed only when marked optional. |
| `maxReplicas`        | `number`                  | Yes      | Passed using this declared shape; omission is allowed only when marked optional. |
| `scaleUpRps`         | `number`                  | Yes      | Passed using this declared shape; omission is allowed only when marked optional. |
| `scaleDownRps`       | `number`                  | Yes      | Passed using this declared shape; omission is allowed only when marked optional. |
| `cooldownMs`         | `number`                  | Yes      | Passed using this declared shape; omission is allowed only when marked optional. |
| `consecutiveSamples` | `number`                  | Yes      | Passed using this declared shape; omission is allowed only when marked optional. |
| `sampleIntervalMs`   | `number`                  | Yes      | Passed using this declared shape; omission is allowed only when marked optional. |
| `windowMs`           | `number`                  | Yes      | Passed using this declared shape; omission is allowed only when marked optional. |

### LiveFleetHealthOptions

| Option            | Accepted TypeScript value | Required | Meaning                                                                          |
| ----------------- | ------------------------- | -------- | -------------------------------------------------------------------------------- |
| `maxErrorRate`    | `number`                  | No       | Passed using this declared shape; omission is allowed only when marked optional. |
| `maxP95LatencyMs` | `number`                  | No       | Passed using this declared shape; omission is allowed only when marked optional. |
| `maxMetricAgeMs`  | `number`                  | No       | Passed using this declared shape; omission is allowed only when marked optional. |

### ProxyAdmissionOptions

| Option              | Accepted TypeScript value | Required | Meaning                                                                 |
| ------------------- | ------------------------- | -------- | ----------------------------------------------------------------------- |
| `ratePerSecond`     | `number`                  | No       | Sustained new requests per second for each direct client IP.            |
| `burst`             | `number`                  | No       | Maximum burst per client; defaults to ratePerSecond.                    |
| `maxActiveRequests` | `number`                  | No       | Maximum number of upstream HTTP requests globally in flight.            |
| `maxBodyBytes`      | `number`                  | No       | Streaming request body size limit. Also checked against Content-Length. |

## Application-owned protocol adapters

Import `createTransportRegistry`, `createHttpTransportAdapter`,
`createWebSocketTransportAdapter`, `createProtocolGateway` and
`transportCapabilityMatrix` from `zhost/protocols`. Register only trusted adapter
factories; unknown transport names fail without package discovery. Registration
is inert, startup checks readiness, and failure rolls back partial resources in
reverse order. Numeric-IP binds reject duplicate fixed ports before startup;
port zero is supported for disposable listeners.

The HTTP adapter owns its gateway and requires explicit authorization, including
loopback. Gateway `authorizeEndpoint` adds per-path policy; method/tool handlers
still own tenant and operation authorization. Forwarding headers are not proof
of identity. WebSocket upgrades require their own authorization and explicit
browser origin allowances. Message sizes, work concurrency and cooperative
signals are bounded; a disconnected handler retains capacity until it settles.
Stop closes admission, signals work, closes streams/sockets, then drains actual
pending work. Uncooperative JavaScript is not forcibly cancelled.

The runtime matrix distinguishes native HTTP/WS/SSE/JSON-RPC from JSON-only
Connect/tRPC and stateless MCP core subsets. Existing gRPC support forwards
HTTP/2 frames to an upstream; there is no native gRPC service adapter. QUIC and
WebTransport require explicitly configured external implementations. Generic SSE
is not MCP resumable Streamable HTTP. The package README includes a runnable
public-import factory and the detailed limits/authentication matrix.

## Proxy overload and bounded drain

Use `createProxyServer({ getRoutes, proxyPort, limits })` with explicit workload
budgets. Optional `maxConnections` caps TCP sockets, `maxActivePerClient` caps
concurrent work per direct socket peer, and `maxActiveStreams` caps upgraded
WebSockets within the existing global `maxActiveRequests` ceiling. HTTP/SSE and
HTTP/2 requests share the global ceiling. Forwarded headers cannot change peer
identity, and a shared front proxy groups callers unless the owning application
applies a separate authenticated identity policy. Limits are process-local;
distributed rate accounting remains the security package's atomic-store contract.

`proxy.getAdmissionStatus()` exposes fixed counters without peer/URL labels.
Capacity rejects with 503; configured rate exhaustion rejects with 429. There is
no unbounded queue or automatic replay. TCP overload can close a connection before
HTTP exists. The package README contains a public-import listener factory.

Piping preserves upload/download backpressure; downstream disconnect destroys
identity and compressed response sources. Partially delivered POSTs are never
rerouted to another backend. Existing cache privacy, health selection, rolling
replacement and restart budgets remain authoritative. Drain accepts integer
0–300000ms deadlines and closes remaining sockets after waiting; it does not prove
rollback of an upstream operation. Focused loopback workloads test overload and
cleanup, without claiming a production performance gain.

## Private local state and event streams

Development `startServer` consumers can set `dev.watchIgnoredPaths` to validated
relative directories. `createDevelopmentWatchIgnore` snapshots the list before
watch traversal, so one application’s private data does not trigger source reloads
or change another subscriber’s configuration. Absolute/traversal paths reject.

For authenticated events, provide `events: new SseHub({privateCache:true})` to
`createProtocolGateway`. This emits `private, no-store, no-transform`; the legacy
cache policy remains the default. Hosts still own finite SSE connection lifetimes,
which are distinct from the gateway dispatch deadline.


## Public entry points

Manifest reference for `zhost@0.0.4`.

```json
{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./protocols": {
      "types": "./dist/protocols/index.d.ts",
      "import": "./dist/protocols/index.js"
    },
    "./compression": {
      "types": "./dist/compression.d.ts",
      "import": "./dist/compression.js"
    }
  },
  "engines": {},
  "dependencies": {
    "chalk": "^4.1.2",
    "ws": "^8.22.0",
    "zforge": "0.0.4",
    "zlook": "0.0.4"
  },
  "peerDependencies": {},
  "bin": {
    "zerux-server": "./dist/bin/zerux.js"
  }
}
```


## Package README

# zhost

The core server orchestration engine for **ZeruxJS**. `zhost` manages the lifecycle of both the application server and the development environment, providing a seamless "zero-config" experience.

## Features

- **Dual Server Management**: Starts and manages your application server alongside a dedicated developer tools server.
- **Hot-Reloading**: Integrated with `zlook` for intelligent, fast restarts on file changes.
- **Zero-Port Management**: Automatically finds available ports for your services to avoid "Address in Use" errors.
- **Portless Proxies**: Support for local domain aliases (e.g., `my-app.localhost`) for a production-like local development experience.
- **WebSocket Gateway**: Built-in WS server for real-time communication between the framework and devtools.

## Usage

### Starting a Server

```typescript
import { startServer } from 'zhost';

await startServer({
    app: {
        name: 'my-app',
        func: (req, res) => {
            res.end('Hello from Zerux!');
        }
    },
    dev: {
        port: 4000,
        watchFunc: async (file) => {
            console.log('Reloading due to change in:', file);
        }
    }
});
```

## Integrations

`zhost` acts as the glue between several core components:

- **`zlook`**: For monitoring source changes.
- **`zforge`**: For devtools registration and dashboard management.
- **`ws`**: For the real-time event pipeline.
- **`portless`**: For internal local-domain routing.

## Development Mode

When running in development mode (`details.dev` provided), `zhost` will:

1. Register the application with the shared dev server.
2. Inject the development client into all HTML responses.
3. Establish a WebSocket connection for hot updates.
4. Set up local domain aliases if supported.

---

<p align="center">
  The engine powering ZeruxJS Applications.
</p>

## Proxy request hardening

The portless proxy now validates Host / HTTP/2 authority before routing, handles IPv6 authorities, and prefers the longest matching registered suffix. Untrusted `Forwarded` and `X-Forwarded-*` input is discarded; the proxy reconstructs forwarding headers from the socket and requested authority. Malformed hop counts are rejected, and upstream HTTP and WebSocket handshake requests have a 30-second socket timeout.

Run focused regression tests after building: `node --experimental-strip-types --test test/request-security.test.ts`. This local development proxy is **not** a substitute for an authenticated public reverse proxy.

## Proxy lifecycle and WebSocket behavior

The portless proxy supports HTTP/1.1, HTTP/2 over TLS, and HTTP/1.1 WebSocket upgrades. Upgrade handshake bytes are forwarded only after the upstream returns `101 Switching Protocols`; both tunnel sockets are cleaned up on disconnect. A 30-second upstream handshake timeout applies before an upgrade, while an established WebSocket tunnel is not subject to this handshake timeout.

`portlessStop()` rejects malformed PID files and refuses to signal a process unless the expected proxy is responding. If the PID no longer exists, it removes the stale marker. **Note:** an HTTP health check does not establish cryptographic process identity; do not share the proxy state directory with untrusted users. The proxy remains intended for local development. A bounded connection drain is available through `connectionTracker.drain`; stronger process ownership verification still requires a separate trusted control boundary.

Focused regression tests are in `test/websocket-proxy.test.ts` and `test/lifecycle.test.ts`.

### Atomic proxy state and recovery

`zhost` now writes `proxy.state.json` as the **last, atomic commit record**
containing the port, TLS flag, TLD and verified process marker. The existing
`proxy.port`, `proxy.tls`, `proxy.tld` and `proxy.pid` files remain for older
consumers. Internal discovery reads the committed record as one snapshot instead
of combining independently updated files.

A `.proxy-startup-journal.json` left behind by an interrupted publication
causes state readers to fail closed with `PendingStartupTransactionError`.
The next coordinated startup recovers the journal under its directory lock,
subject to conflict and live-owner checks. Manual consumers can call
`recoverInterruptedStartup(stateDir)` **only while holding**
`withOperationLock(stateDir, ...)`.

Legacy installations without `proxy.state.json` are read using a verified
double snapshot until their next successful startup. Legacy reads cannot offer
the same atomic guarantee as a commit record; do not edit state files manually.
A malformed commit record or unresolved journal is not silently ignored.

## Safe route lifecycle and registry protection

Applications launched with `portlessRun()` now receive a unique route lease.
When the child command finishes, its cleanup only removes **that registration**;
if another app has replaced the hostname (including `--force` takeover), the
older app cannot delete the replacement. Explicit administrative `removeRoute()`
remains unconditional, and the original `addRoute()` API retains the legacy
on-disk route format.

For integrations that manage application processes themselves:

```ts
import { RouteStore } from 'zhost';

const routes = new RouteStore('/path/to/your/proxy-state');
const lease = routes.registerRoute('myapp.localhost', 4400, process.pid);
try {
  // Serve requests while this registration is active.
} finally {
  lease.release(); // returns false if another owner replaced this route
}
```

The route registry rejects malformed, duplicated, oversized, or symlinked
`routes.json` files when making changes rather than silently overwriting them.
Reader APIs remain lenient for UI display. The route mutation lock stores an
OS-specific process identity: it will not steal a slow, active owner's lock
based on file age alone. Verified dead owners can be recovered. An ownerless,
malformed, or unverifiable lock requires manual inspection.

To run the route-focused regression tests after compiling the package:
`node --experimental-strip-types --test test/routes.test.ts test/route-leases.test.ts`.

**Deployment note:** This continues to be a local-development proxy. Route
leases prevent accidental cleanup races; they do not provide user isolation,
authenticated registration, or a hardened internet-facing control plane.

## Application supervision and readiness

`zerux-server run` now launches application commands **without shell interpolation**,
checks for an already occupied application port, waits for a TCP listener on
`127.0.0.1`, and **only then** publishes the hostname route. If the process
crashes, fails to spawn, or misses its readiness deadline, no route is published
and the child is stopped/reaped. The parent forwards SIGINT/SIGTERM, sends them
to the entire spawned POSIX process group, waits up to a bounded timeout, and
uses SIGKILL if necessary. Route cleanup remains ownership-aware.

Diagnostic PID lookup validates a primitive TCP port before using fixed native
argument arrays for `lsof`/branch/v0.0.4/`netstat`; it never interpolates that port into a
shell command. A returned PID is diagnostic evidence, not permission to stop
a process. Native tool availability and platform trust remain host concerns.

```bash
zerux-server proxy start --port 1355
zerux-server run --name web --app-port 4300 --ready-timeout 20000 -- npm run dev
zerux-server run --name api --shutdown-timeout 8000 -- node server.mjs
zerux-server web node server.mjs    # short named form
```

All **launcher flags precede the application command** (optionally separated by
`--`). Anything after the command is passed to the child verbatim, including
`--port`, `--name` and `--`. Use `--no-ready` only for long-running processes
without a TCP readiness endpoint; in this mode a route can be published before
the app is actually ready. When supplying `--app-port`, the port must be free.

For library integrations, `launchSupervisedCommand(argv, { port, onExit })`
returns a `SupervisedProcess` with `.completed`, `.running`, and asynchronous
`.stop()`. `spawnCommand` is retained as a compatibility wrapper, but new code
should use the supervisor API. On Windows, direct execution does not invoke a
shell; prefer executable programs or invoke scripts through `node` explicitly.

**Limitations:** Basic TCP readiness confirms a listener, not an HTTP health endpoint;
free-port discovery cannot eliminate races before the child binds. Child-process
group termination is strongest on POSIX; native Windows process-tree supervision
still needs its own integration suite. This tool is intended for local developer
applications, not untrusted multi-tenant process execution.

Focused checks after compilation: `node --experimental-strip-types --test test/process-supervisor.test.ts
test/cli-run-arguments.test.ts test/app-supervision-integration.test.ts`.

## HTTP health checks and bounded restart policies

When an application supplies an HTTP health endpoint, ZHOST can wait for a
successful status (HTTP 200–299) before publishing a route. Health requests are
GET requests to `127.0.0.1`, do not follow redirects, have bounded timeouts,
and discard response bodies. A failing runtime health check removes the route,
stops the unhealthy child, and optionally starts a new process.

```bash
zerux-server run --name api --app-port 4300 \
  --health-path /health --health-timeout 1000 \
  --health-interval 5000 --health-failures 3 \
  --restart on-failure --max-restarts 5 \
  --restart-delay 250 --restart-window 60000 \
  -- node server.mjs
```

`--restart` accepts `never` (default, preserves previous behavior),
`on-failure`, and `always`. Restarts have capped exponential delays. The
`--max-restarts` quota applies within the `--restart-window` time interval,
and failed restarts also count toward the budget. Once exhausted, the wrapper
exits with a failure. `--health-path` enables HTTP readiness and 10-second
runtime monitoring by default. `--health-interval` adjusts this cadence.
`--health-failures` is the number of consecutive failed probes before the
supervisor stops a running process. With no HTTP path, monitoring checks TCP
connectivity. A `--no-ready` application cannot enable health monitoring.

For library clients, use `launchManagedApplication(argv, { port, health,
restartPolicy, onReady })`. `onReady` may return a disposal function (such as
releasing a route lease). It is called after each successful readiness check;
the disposer is called on exit before any restart. The controller has
`.current`, `.running`, `.completed`, and `.stop()`. Additional exports include
`probeApplicationHealth()` and `validateManagedOptions()`.

**Production boundary:** Restart budgets and counters exist only in the
supervising process; restarts are sequential and not zero-downtime. HTTP probes
do not authenticate the application's content, and cannot replace production
load-balancer health checks. The supervisor is for trusted local applications,
not a privilege boundary for executing untrusted commands. Windows process tree
behavior and cross-process recovery still need native integration testing.

Regression suite (after building dependencies and `zhost`):
`node --experimental-strip-types --test test/*.test.ts`.

## Runtime status and coordinated manual reload

Managed application state is now observable via `handle.status` and
`handle.subscribe(listener)`. Snapshots include lifecycle phase, child PID,
ready-generation number, automatic restart count, previous exit and timestamp.
Subscribe callbacks are isolated so a throwing observer cannot take down an app.

```ts
const app = await launchManagedApplication(['node', 'server.mjs'], {
  port: 4300,
  onReady: child => registerRouteForChild(child),
});
const unsubscribe = app.subscribe(status => console.log(status.phase));
await app.restart(); // sequential: remove old route, stop child, await readiness, publish new route
unsubscribe();
await app.stop();
```

Concurrent `.restart()` calls coalesce into one request and an explicit restart
**does not use the automatic failure budget**. A stopped app refuses manual
restart. Manual restarts are sequential, **not zero-downtime**; the route is
unpublished while its child restarts.

To enable a manual restart signal for a CLI-launched app on Linux/macOS:

```bash
zerux-server run --name api --app-port 4300 --reload-signal -- node server.mjs
# Send SIGUSR2 to the wrapper PID shown in `zerux-server status api`.
zerux-server status api
zerux-server status --json
```

`--reload-signal` is opt-in and is not installed on Windows. Only signal a
wrapper you own; the status command **never sends signals**. Status JSON lives
under a per-user `app-status-<uid>` folder inside the proxy state directory;
files are private (0600), atomically rewritten, and checked for malformed
content or symlinks. They do **not** contain CLI arguments, environment
variables, or credentials. `alive` requires verification of the wrapper's
process creation identity, not merely the PID. A `stale` result indicates
that the previous status cannot be verified as live; it is **not** a positive
application-health result. Historical terminal snapshots are retained for
troubleshooting, and may be removed manually from the private status folder.

This is local-machine observability, not a distributed control plane or an
authenticated remote management API. End-to-end Linux testing is included;
native macOS/Windows behavior requires additional platform verification.

## Multi-instance routing and safe traffic handovers

Route pools can distribute **new HTTP requests and WebSocket upgrades** across
up to 32 local application instances using smooth weighted round-robin. Legacy
single-port routes are unchanged. The proxy selects only loopback ports; it
never accepts remote backend addresses from route files.

```ts
import { RouteStore, handoverBackends, createProxyServer } from 'zhost';

const routes = new RouteStore('/path/to/proxy-state');
const lease = routes.registerPoolRoute('api.localhost', [
  { port: 4300, weight: 3 },
  { port: 4301, weight: 1 },
], process.pid);

const server = createProxyServer({
  getRoutes: () => routes.loadRoutes(),
  proxyPort: 1355,
  backendHealth: {
    intervalMs: 5_000,
    failureThreshold: 2,
    cooldownMs: 5_000,
    probe: { path: '/health', timeoutMs: 800 },
  },
});

server.listen(1355);
console.log(server.backendPool.status());
await server.backendPool.checkNow(); // optional immediate health sweep

// Run the new instance first. Only commit after its health probe succeeds.
const committed = await handoverBackends(lease, [{ port: 4400 }], {
  health: { path: '/health', timeoutMs: 1_000 },
});
if (committed) {
  // New requests go to 4400. Keep old 4300/4301 processes alive until
  // their existing HTTP requests and WebSocket connections have drained.
}
// When finished: lease.release(); server.close();
```

- `RouteStore.registerPoolRoute()` saves validated weighted targets atomically
  and returns a lease. `lease.switchBackends()` swaps targets under the same
  route lock and fails safely if another process took over the hostname.
- `handoverBackends()` checks the _new_ targets before committing; failed probes
  or invalid targets leave the currently published route unchanged.
- The proxy isolates failed upstream connections. With an active probe interval,
  isolated instances recover only after a successful check. Without an interval,
  passive failures recover after the configured cooldown. When all instances
  are unhealthy, new requests receive HTTP **503** rather than being routed to
  a known failed instance.
- `BackendPool.status()` exposes per-instance health and failure information.
  Background health probes are bounded to eight concurrent connections and
  cleaned up when the proxy listener closes.
- Manual handovers only switch **new** requests. They do not spawn new child
  servers, kill old ones, migrate existing WebSocket sessions, or guarantee
  uninterrupted availability when the old process is stopped too early. The
  deployment controller must manage the overlap and connection-drain period.
- This is still a local-development proxy. It is not an internet-facing load
  balancer, authenticated service mesh, or distributed routing coordinator.

After a package build, run `node --experimental-strip-types --test test/backend-*.test.ts` for the new
pool, handover, and WebSocket integration regression tests.

## Rolling application deployments

`launchRollingApplication` runs _overlapping_ generations of the same loopback
application on different ports, tests the candidate's readiness, switches the
existing leased hostname atomically, and retires the previous child after its
in-flight proxy connections finish (or a configured drain deadline elapses).
It is an opt-in API; existing `zerux-server run` behavior is unchanged.

The **proxy and controller must share the same `BackendConnections` instance**.
This release does not provide cross-process drain coordination; do not use a
tracker from an unrelated proxy instance to claim uninterrupted deployments.

```ts
import { RouteStore, createProxyServer, launchRollingApplication } from 'zhost';

const store = new RouteStore('./.local-proxy-state');
const proxy = createProxyServer({
  getRoutes: () => store.loadRoutes(),
  proxyPort: 4000,
});
proxy.listen(4000, '127.0.0.1');

const app = await launchRollingApplication(['node', 'server.mjs'], {
  store,
  hostname: 'api.localhost',
  ports: [4300, 4301],
  connections: proxy.backendConnections,
  health: { path: '/health', timeoutMs: 1_000 },
  readyTimeoutMs: 10_000,
  shutdownTimeoutMs: 5_000,
  drainTimeoutMs: 15_000,
  verificationChecks: 3,
  verificationIntervalMs: 200,
});

const result = await app.deploy(); // candidate starts BEFORE traffic switches
console.log(result); // { fromPort, toPort, generation, drained }

await app.stop(); // withdraw route, drain, then stop the child
await proxy.connectionTracker.drain({ timeoutMs: 10_000 });
```

The child server **must bind to `127.0.0.1:${process.env.PORT}`**. Ports must be
available, different, and exclusively owned by this deployment controller.
The controller passes the correct `PORT` to each child even if an existing
`env.PORT` was set. Health probes are loopback-only and do not follow redirects.

### Safety and limitations

- **Pre-publication failure:** failed process start, timeout, or failed initial
  health verification leaves the old route and process untouched.
- **Post-publication failure:** verification detects a failure and conditionally
  switches traffic back to the still-running old instance before stopping the
  candidate. If another process superseded the route lease, this controller
  cannot overwrite it during rollback.
- **Drain:** existing HTTP requests and upgraded WebSockets remain pinned to
  the old backend. `drained: false` means the deadline elapsed, so terminating
  the old process may interrupt remaining sessions.
- **Stop:** removes the route only if this controller still owns its lease;
  waits for current traffic and terminates the child. Repeated stops and
  concurrent deploy requests are coalesced.
- This is a **same-machine, same-process-proxy** orchestration API, not a
  distributed deployment system, persistent supervisor, or cross-process
  zero-downtime controller. It does not automatically roll back a deployment
  after the verification window has ended.

Focused validation: build `zhost`, then run
`node --experimental-strip-types --test test/rolling-app.test.ts test/backend-*.test.ts`.

## Rolling CLI & cross-process deployment control

The rolling controller is now available from the CLI, with **real
proxy-side HTTP/WebSocket drain observations** even when the proxy and the
application wrapper are separate processes. Use an existing ZHOST proxy built
from this version or later; rolling launches deliberately refuse to claim
traffic has drained when they cannot reach its authenticated control endpoint.

```bash
zerux-server proxy start --port 1355
zerux-server rolling run --name api --ports 4300,4301 \
  --health-path /health --drain-timeout 15000 --verify-checks 2 \
  -- node server.mjs
# From a second terminal, under the same operating-system user:
zerux-server rolling deploy api
zerux-server rolling status api --json
zerux-server rolling history api --json
zerux-server rolling stop api
```

`rolling run` keeps a supervising process alive. Deploy starts a replacement on
the spare port, waits for health, switches the leased route, verifies health,
then asks the actual proxy to wait for outstanding HTTP requests and WebSocket
tunnels before stopping the old process. When the drain deadline expires,
`drained: false` is recorded and the old child is stopped. Failure before
cutover leaves the old route available; failure during the verification window
attempts a lease-checked rollback. Deployment commands are serialized by the
supervisor; multiple concurrent requests share the same in-flight deployment.

The CLI control endpoint listens **only** on IPv4 loopback with a random
256-bit bearer token. Its discovery descriptor and bounded deployment history
live in a private, same-user directory beneath the selected proxy state
directory. Descriptors require a verified process-start fingerprint. Requests
and responses are size-limited and time-limited; arbitrary command execution,
file access and unauthenticated process signaling are not exposed through RPC.
`rolling status` displays _last known_ state and separately reports whether the
owning controller is online. History keeps up to 50 events per hostname, with
no application argv or environment secrets saved.

**Operational boundaries:** this is local, single-host orchestration for
trusted application commands, not a publicly exposed management API or a
cross-host control plane. Both proxy and rolling wrapper must be accessible to
the same OS user; a root-owned privileged proxy control is not exposed to
another user. Abrupt supervisor termination (for example `SIGKILL` or power
loss) cannot guarantee cleanup of detached application children; stale routes
are filtered by owner liveness and require inspection. Multi-file crash
consistency, distributed scheduling and external load-balancer draining are
not provided. Run `node --experimental-strip-types --test test/rolling-cli-control.test.ts` against fresh
compiled output for a separate-process smoke test.

### Recovering rolling applications after a hard crash

The rolling wrapper now records its own OS creation fingerprint and the
fingerprints/ports of **each spawned generation** in a private, atomically
replaced `app-controls-<uid>/*.generations.json` file. Recording happens before
readiness and the initial route lease is saved before launch returns. A normal
stop removes this recovery record only after the supervised children have
exited. A new `rolling run` automatically attempts recovery of an abandoned
record while holding the deployment operation lock.

```bash
# Inspect stale status (JSON includes recoveryRequired and orphanGenerations)
zerux-server rolling status api --json

# After an unexpected SIGKILL / controller crash, clean up verified children
zerux-server rolling recover api --dry-run --json
zerux-server rolling recover api --json

# Compact an offline app's audit trail
zerux-server rolling prune api --days 30 --max-events 20
```

Recovery is intentionally conservative: it will not stop a live wrapper, a
process with an unreadable creation fingerprint, or a different process that
has reused an old PID. Recorded route leases are released conditionally, so a
newer owner's route is never removed. Recovery uses process-group signals on
POSIX only when the original group leader can still be identified; if the
leader is already gone but grandchildren survive independently, operator
cleanup may still be needed. It is not a distributed process manager or an
unattended repair tool for other users' processes.

Deployment history now has a default 90-day retention window (in addition to
the existing 50-event maximum). `rolling prune` accepts 0–3650 days and 0–50
events, refuses to modify a live controller's history, and preserves the most
recent status. History is informational and is never a source of authority
for process termination. Cross-platform process identity behavior still needs
native Windows/macOS integration testing.

### Persistent backend health and multi-app reconciliation

ZHOST's built-in proxy now checkpoints recent backend circuit-breaker state in
`app-controls-<uid>/backend-health.json` using private atomic writes. Checkpoints
are bounded, coalesced (250 ms by default), and expire after 60 seconds. Routes
carry a registration lease/process signature: a new registration, different
backend pool, or process takeover **never inherits the previous route's
isolation state**. Late upstream responses from a replaced route cannot clear
or poison the new route's health status. Active health probes can rehabilitate
isolated backends. The standalone `createProxyServer()` API can opt into
checkpoints via `backendHealth.stateDir`; normal `portlessProxy()` configures a
private directory automatically.

```ts
import { BackendPool } from 'zhost';

const pool = new BackendPool(() => routes, {
  intervalMs: 5_000,
  failureThreshold: 2,
  probe: { path: '/health' },
  stateDir: '/private/proxy/health-state', // must already exist and be owned
  stateMaxAgeMs: 60_000,
  persistDebounceMs: 250,
});
pool.syncCurrentRoutes();
console.log(pool.status());
pool.close();
```

`BackendPool` owns its periodic probe and checkpoint timers; `close()` stops
new sweeps, attempts a final checkpoint and clears local state. A probe already
in flight may still settle, but its result cannot update the closed pool.
`checkNow()` coalesces overlapping sweeps by returning immediately while one is
active; it does not return the active sweep's promise. Probe destinations stay
on IPv4 loopback, HTTP redirects are not followed, and response bodies are not
buffered. A checkpoint is recovery metadata, not permission to modify a route.

The proxy also serves read-only per-backend status through its authenticated
local RPC (not over public HTTP):

```bash
zerux-server rolling backends api --json
```

Orphaned rolling controllers can be scanned and reconciled in one operation.
The scan is capped at 128 private records by default; it refuses live owners,
invalid records, and processes whose identity cannot be verified. One corrupt
record does not hide the status of other applications:

```bash
zerux-server rolling reconcile --dry-run --json
zerux-server rolling reconcile --json
```

**Safety boundaries:** Reconciliation is an explicit operator command, not a
periodic background daemon; `rolling run` still automatically recovers its own
abandoned controller before startup. Circuit-breaker checkpoints describe
recent failures and are not durable application or process authority.
No cross-host storage, distributed failover, or persistent metrics database is
provided. State files must remain private to the proxy's operating-system user.

## Health-aware multi-instance process fleets

`launchInstanceFleet()` manages a desired set of local application processes and
publishes their backend ports through a conditional route lease. It detects
crashes and health failures, isolates unhealthy instances, launches verified
replacements using a bounded restart budget, and drains retired HTTP/WebSocket
connections before terminating the old generation. This complements the
single-instance `launchRollingApplication()` API.

```ts
import { launchInstanceFleet, RouteStore } from 'zhost';

const fleet = await launchInstanceFleet(['node', 'server.mjs'], {
  store: new RouteStore('/path/to/state'),
  hostname: 'api.localhost',
  ports: [4300, 4301, 4302], // two running instances plus a warm spare port
  replicas: 2,
  connections: proxy.backendConnections, // must be the *serving* proxy's tracker
  health: { path: '/health' },
  healthIntervalMs: 2000,
  unhealthyThreshold: 2,
  maxReplacements: 5,
  restartWindowMs: 60_000,
});

console.log(fleet.status);
await fleet.reconcile(); // optional immediate health check and repair
await fleet.stop();
```

The fleet API is exported from the root package. Create the proxy using your
existing ZHOST proxy setup; do not create a separate empty tracker for draining.
`fleet.stop()` is required during application shutdown. Restart budgets are
currently in memory and the API does not perform cross-process ownership
recovery automatically.

## Persistent multi-instance fleet CLI

`zerux-server fleet` connects the multi-instance supervisor to the
real proxy's private authenticated control endpoint. Unlike launching the API
from another process, the CLI uses the proxy's **actual** connection tracker
for draining; it refuses to launch when that control is unavailable.

```bash
# The proxy must be running or startable by zerux-server.
zerux-server fleet run \
  --name api --ports 4300,4301,4302 --replicas 2 \
  --health-path /health --health-interval 2000 \
  --unhealthy-threshold 2 --max-replacements 5 \
  --restart-window 60000 --restart-delay 100 \
  --max-restart-delay 5000 --drain-timeout 10000 \
  -- node server.mjs

# In another terminal:
zerux-server fleet status api --json
zerux-server fleet backends api --json
zerux-server fleet reconcile api --json
zerux-server fleet stop api --json

# If a supervisor is forcibly killed, preview and perform verified cleanup:
zerux-server fleet recover api --dry-run --json
zerux-server fleet recover api --json

# To replace a fleet's command, port or replica policy, after stopping and
# recovering any orphaned generations, explicitly discard the old checkpoint:
zerux-server fleet reset api
```

A strict `--` separator protects application arguments from being interpreted
as supervisor flags. The `run` command accepts 2–16 replicas and at least one
spare port. Application children bind loopback `PORT`; an optional HTTP health
path must be an origin-form relative path. A successful initial health check
is mandatory before the route is published.

The controller records PID + OS process-start fingerprints for every detached
generation **before readiness**, and saves its route lease before returning a
running supervisor. Live operation uses a local authenticated loopback control
socket with private 0600 descriptors. CLI status displays the last persisted
checkpoint and distinguishes online from offline or recovery-required state.

The restart-budget checkpoint is a 0600 JSON file under the owner's private
`app-controls-<uid>` directory. It stores retry timestamps _before_ each
attempt. A supervisor restarted with the same configuration inherits the
remaining sliding-window budget, preventing hard crashes from resetting the
attempt limit. Configuration fingerprints intentionally reject changed commands,
ports, replica counts and restart windows unless the operator explicitly resets
the checkpoint with `fleet reset` while offline and fully recovered.

Initial startup rejects if an already-ready replica exits while another is still
starting. The controller stops its remaining children and publishes no route;
background reconciliation begins only after the initial route lease exists.

**Operational limits:** This manages local processes, not distributed replicas.
The proxy must remain accessible via authenticated local control. Recovery
refuses live or unverifiable owners and cannot always discover orphaned process
grandchildren after their original leader disappears. Status is a last-known
snapshot if its writer encounters a disk error; the restart-budget persistence
hook fails closed rather than silently allowing a new replacement. A health
failure can temporarily reduce serving capacity. Full native Windows/macOS
integration and long-duration deployment tests are still required.

## Dynamic fleet capacity and request-rate autoscaling

A fleet can now change its desired replica count while running. Each new replica
is started and health-checked **before** its port is atomically added to the
route lease. Scale-down removes the selected port from new traffic first, waits
for in-flight HTTP and WebSocket connections (up to the configured drain
limit), then terminates the child. The fleet keeps at least one spare port.

```sh
zerux-server fleet run --name api --ports 4300,4301,4302,4303,4304 \
  --replicas 2 --health-path /health -- node app.mjs

zerux-server fleet scale api 3 --json
zerux-server fleet metrics api --json
zerux-server fleet doctor api --json
zerux-server fleet history api --json
```

### Automatic capacity policy

```sh
zerux-server fleet run --name api \
  --ports 4300,4301,4302,4303,4304 --replicas 2 \
  --health-path /health --autoscale --min-replicas 2 --max-replicas 4 \
  --scale-up-rps 25 --scale-down-rps 5 --scale-samples 3 \
  --scale-interval 5000 --scale-window 30000 --scale-cooldown 30000 \
  -- node app.mjs

zerux-server fleet autoscale api pause
zerux-server fleet autoscale api resume
```

Autoscaling uses **requests per second per active healthy replica**, observed
by the _real proxy_ rather than the fleet supervisor. The policy scales one
replica per decision, requires consecutive samples, uses different scale-up and
scale-down thresholds, and honors a cooldown. A manual `fleet scale` pauses
autoscaling until explicitly resumed. A failed or stale metrics request cannot
trigger a downscale; degraded fleets do not automatically downscale.

Only 2–16 replicas are supported, and the reserved port pool must contain
at least one extra port beyond the configured maximum. Scale operations are
serialized with health reconciliation. A partial scale-up error retains
healthy serving instances; reconciliation repairs missing desired capacity
within the durable replacement budget.

### Persisted state and troubleshooting

Fleet checkpoints retain desired replica count, restart attempt timestamps,
autoscaling pause state, and cooldown timestamp. Restart with the **same**
command, ports, baseline replica count, and scaling policy to restore state.
Configuration changes require deliberate checkpoint reset after verifying
all old generations are stopped. Private event history retains up to 100
lifecycle events for 30 days; `fleet doctor` reports offline owners, capacity
shortfalls, route mismatch and stale checkpoints without changing processes.

**Operational caveats:** metrics are a bounded in-memory rolling window and
reset when the proxy restarts; they are not an observability database. A drain
limit can disconnect long-lived WebSockets. Scaling is local-process
orchestration, not cluster autoscaling; it does not provision machines, limit
CPU/memory usage, or distribute control across hosts. The OS and full app
integration still need validation in your deployment environment.

## Fleet observability and resource autoscaling

The fleet controller supports request-rate scaling (default) and Linux child-process
resource scaling. Samples are based on **the serving child processes**, not the
ZHOST controller process. Memory is the mean resident set size (RSS) in MiB per
replica; CPU usage is a percentage of **one CPU core** per replica (and may
exceed 100% for multithreaded work).

```sh
# CPU-based scaling, 2–4 active workers, plus a spare slot
zerux-server fleet run --name api --ports 4300,4301,4302,4303,4304 \
  --replicas 2 --autoscale --min-replicas 2 --max-replicas 4 \
  --scale-mode cpu --scale-up 75 --scale-down 25 \
  --scale-samples 3 --scale-cooldown 30000 \
  --scale-max-error-rate 0.05 --scale-max-p95 1500 \
  --health-path /health -- node app.mjs

# Memory-based scaling (MiB per live child)
zerux-server fleet run --name worker --ports 4400,4401,4402,4403 \
  --replicas 2 --autoscale --min-replicas 2 --max-replicas 3 \
  --scale-mode memory --scale-up 768 --scale-down 256 \
  -- node worker.mjs

# Service-level and resource observations
zerux-server fleet metrics api --json
zerux-server fleet metrics api --prometheus
zerux-server fleet doctor api --live --json
zerux-server fleet samples api --json
```

Existing request-rate flags (`--scale-up-rps`, `--scale-down-rps`) continue to
work. For resource modes, provide `--scale-up` and `--scale-down`. On non-Linux
platforms, resource autoscaling fails at startup instead of emitting unsafe
zero-load observations. The first CPU sample warms up its counter baseline;
a replaced PID must also warm up before new readings can be trusted.

**Scale-down safeguards:** `--scale-max-error-rate` and `--scale-max-p95` require
recent, authenticated proxy telemetry. A response-error ratio or p95 latency
above the configured limit prevents a scale-down decision; malformed, missing,
or stale telemetry blocks sampling. WebSocket connection lifetimes are excluded
from HTTP latency measurements. Recent HTTP completion history is bounded per
backend, so p95 is an approximate percentile of the retained sample rather
than a full histogram. No server metrics endpoint is publicly exposed.

Metrics exports are read through the existing authenticated local CLI control.
The `--prometheus` format prints text to stdout for collection by an operator;
it does **not** enable an unauthenticated HTTP endpoint. Private autoscaling
observation history is retained for 24 hours, capped at 240 points, and written
no more frequently than every 30 seconds. State files remain owner-only and
symlinked or malformed files are rejected. A `fleet doctor --live` inspection
cross-checks real backend readiness, error rate and p95 latency; the original
offline `fleet doctor` continues to inspect durable state and ownership.

For production, always benchmark target workload, tune scale thresholds and
cooldowns, and test failures under sustained load. Linux `/proc` resource
monitoring is local-only, uses the operating system's conventional 100 Hz user
clock, and does not measure subprocess descendants or container-wide memory.
The metrics and scaling policy do not replace a distributed orchestrator,
container limits, autoscaler SLOs, or a monitoring time-series database.

## Production consolidation: proxy, TLS, development server and resource limits

The management proxy now **listens on `127.0.0.1` by default**, not every
network interface. This is important because registered development apps and
fleet controls are meant to be local. To deliberately expose the proxy on a
network, specify an address explicitly:

```sh
zerux-server proxy start --port 1355 --bind 0.0.0.0
```

Only numeric IP addresses and `localhost` are accepted for `--bind`. Binding
publicly is an operator decision: the proxy is not an Internet-facing ingress
controller with authentication, rate limiting, WAF, or tenant isolation.

When `--https` is selected, the proxy **rejects plaintext HTTP** on the TLS
port by default with HTTP 426. Library consumers that explicitly need the
legacy dual-protocol port can pass `allowPlainHttp: true` to
`createProxyServer`. Certificates generated by the local CA no longer cause
an automatic machine-wide trust-store update. Use the **explicit**
`zerux-server trust` command after inspecting the CA and understanding its
implications. Dynamic TLS certificates are restricted to the configured local
TLD namespace and exact requested hostnames. Cached contexts and concurrent
certificate generation are bounded.

TLS liveness probes verify the private state directory's bounded `ca.pem` and
configured hostname. A missing, mismatched or untrusted CA returns an unsuccessful
probe; verification is never disabled. This does not install system trust.

HTTP request and response headers now filter both fixed hop-by-hop headers
and the additional fields nominated by `Connection`; WebSocket handshakes
retain the necessary upgrade headers. Managed `/etc/hosts` entries are
validated and malformed managed blocks fail closed.

The dev-server implementation is now under `src/dev/`, with `src/index.ts`
remaining the package's public export surface. Dev HTML injection is limited
to 2 MiB of identity-encoded HTML; larger and compressed responses are
forwarded without injection rather than buffered unboundedly. Responses with an
enforced Content-Security-Policy header (including raw writeHead pairs) or an
explicit flushHeaders call also pass through unchanged. The application owns its
headers and streamed nonce; the injector never guesses a nonce or widens CSP.
For deliberate manual integration, zforge's public injectDevClient accepts a
response-owned nonce, but the host must separately approve styles, connections
and devtools frame origins. Configure devtools.frameAncestors with exact origins
for development embedding; production devtools documents ignore those grants.
See [zforge framing policy](https://github.com/zeruxjs/zeruxjs/blob/version/v0.0.4/packages/zforge/README.md#document-policy-and-framing).
The dev app
listens on loopback, filesystem watch reloads are serialized, and shutdown
releases its watcher without stopping unrelated applications' proxy.

For Linux fleets, `readLinuxContainerLimits()` reports cgroup v1/v2 CPU quota
and memory limit/usage where available. These container-wide figures are
**diagnostics**, not an invented per-child memory/CPU reading; missing and
unlimited constraints are represented as `null`.

### Compatibility notes

- A proxy intentionally accessed from another device now needs `--bind`.
- Mixed HTTP and HTTPS on the same port now requires `allowPlainHttp: true`.
- Trusting a new local certificate authority is now an explicit operation.
- `startServer` no longer stops a potentially shared proxy when its dev app exits.
- HTTP responses whose bodies are too large or encoded are no longer altered to inject the dev client.

### Verification and limits

The focused TypeScript check and regression tests exercise request filtering,
loopback binding validation, local certificates, hosts blocks, HTTPS downgrade
handling, dev HTML buffering, cgroup parsing, and the established routing,
WebSocket, and fleet APIs. A full monorepo install, native macOS/Windows test,
external exposure penetration test, and production load test are still required
before calling a deployment production-certified.

### Optional proxy request limits

Configure limits before binding; duplicate flags, partial numbers, and invalid
ranges are rejected:

```sh
zerux-server proxy start --port 1355 \
  --rate 100 --burst 200 --max-active 500 --max-body 67108864
```

Equivalent library options are available through `createProxyServer({limits})`.
`--rate` is a per-direct-IP sustained request rate, `--burst` is its token-bucket
capacity, `--max-active` is a global count of concurrent HTTP and upgraded
connections, and `--max-body` is a streamed request-body byte limit. Limits
are opt-in for compatibility. They are local process protections, **not**
a substitute for an authenticated public reverse proxy or distributed DDoS
protection. Size violations are rejected with HTTP 413, rate violations with
429, and concurrency overload with 503. Backend code may have seen a partial
chunked request before a streaming size violation is detected.

## Integrated protocol gateway

**MCP has moved into `zhost`**: import `createMcpServer`, `ToolRegistry`,
`ResourceRegistry` and `createProtocolGateway` from `zhost`. The old
`@zeruxjs/mcp` package has been removed. MCP implements the stateless core
(tool/resource calls, initialize, ping) over Streamable HTTP JSON POST.
It is **not** a complete implementation of the MCP 2026-07-28 protocol
(subscriptions, elicitation, sampling, full metadata validation, etc.).

```ts
import {
  createMcpServer, createProtocolGateway, createProxyServer,
} from 'zhost';

const mcp = createMcpServer({ name: 'my-app', version: '1.0.0' });
mcp.tools.register({
  name: 'current_load',
  handler: () => proxy.getLoadStatus(),
});
const protocols = createProtocolGateway({ mcp });
protocols.rpc.register('app.ping', () => ({ ok: true }));
protocols.connect.register('app.v1.StatusService', 'Get', () => ({ ready: true }));
protocols.trpc.register('status.read', () => ({ ready: true }));
protocols.events.publish('ready', { message: 'online' });

const proxy = createProxyServer({
  proxyPort: 1355,
  getRoutes: () => [{ hostname: 'my-app.localhost', port: 4400 }],
  protocols,
});
proxy.listen(1355, '127.0.0.1');
console.log(proxy.getLoadStatus()); // includes all HTTP status families
console.log(proxy.getApplicationLoad('my-app.localhost'));
```

Protocol endpoints are `POST /mcp`, `POST /rpc`,
`POST /<package.Service>/<Method>` for **Connect unary JSON**, `POST /trpc/<procedure>`
for the basic tRPC-compatible procedure bridge, and `GET /events` for SSE.
Authenticated applications can supply `events: new SseHub({privateCache:true})`
to emit `private, no-store, no-transform`. Omitting `privateCache` preserves the
legacy `no-cache, no-transform` policy. Event-stream connection lifetimes remain
a host responsibility; the gateway's dispatch deadline is not an SSE lifetime.

The Connect and tRPC bridges are lightweight JSON subsets: use their official
framework adapters for full schema, codecs, bidirectional streams and client
compatibility. gRPC-over-HTTP/2 requests with `application/grpc` are forwarded
as streaming HTTP/2 to an h2c backend; other routes continue to use HTTP/1.1.
Keep these endpoints behind authentication if accessible outside a trusted local
machine. The gateway by default allows only loopback connections, validates
browser Origin, and caps request bodies. Supply `authorize(req)` for explicit
remote access control. Tool executions must also implement their own permission
checks before accessing sensitive data.

### HTTP versions and QUIC

| Version/transport  | Support                                                                                            |
| ------------------ | -------------------------------------------------------------------------------------------------- |
| HTTP/1.0, HTTP/1.1 | Native Node HTTP server                                                                            |
| HTTPS and HTTP/2   | TLS ALPN negotiation, HTTP/1.1 fallback                                                            |
| HTTP/3 (QUIC)      | **External HTTP/3 terminator required**; optional validated `Alt-Svc` advertising                  |
| WebTransport       | **External QUIC terminator required**; `WebTransportRouter` lets a terminator attach real sessions |
| WebSocket          | HTTP/1.1 upgrade proxy                                                                             |
| SSE                | Native streamed HTTP event source                                                                  |

Node 22's HTTP modules do not provide native HTTP/3 or WebTransport server APIs.
Do not configure `http3Terminator` unless a real QUIC listener is running at
that port with valid TLS certificates. For example:

```ts
const proxy = createProxyServer({
  proxyPort: 1355,
  getRoutes: () => routes,
  tls: { key, cert },
  http3Terminator: { enabled: true, port: 443, webTransport: true },
});
console.log(proxy.capabilities);
```

`http3Terminator` only publishes an `Alt-Svc` hint and capability metadata;
actual QUIC termination, HTTP/3 forwarding and WebTransport negotiation **must**
be provided by the external server. HTTP/2 ALPN happens automatically on the
native TLS server. Plain HTTP is not silently upgraded to TLS or QUIC.

`proxy.getLoadStatus(windowMs?)` reports in-flight HTTP requests, request
arrival totals, protocol versions, response counts by status code and status
family (1xx–5xx), per-route latency and RPS, plus requests handled outside
registered application routes. `proxy.backendConnections.metrics()` continues
to provide backend-specific fleet telemetry. Aborted requests use a synthetic
`499` in the load API, not an HTTP status sent to the client.

Connect JSON streaming can be registered with
`protocols.connectStream.register('app.v1.Events', 'Watch', async function* (messages) { ... })`.
It uses Connect's five-byte framed `application/connect+json` responses,
including the end-stream envelope. Streaming is size bounded and does not
support compression or Protobuf encoding in this built-in lightweight adapter.

For application-specific load and Linux resource usage, call
`await proxy.getApplicationUsage('my-app.localhost', [workerPid])`. This returns
per-route HTTP response family counts, active upstream connections and optional
CPU/memory samples for **explicit** worker PIDs (first CPU sample is a warmup).
If you omit the PIDs, resource monitoring is not requested; unverified process
identities never produce fabricated zero-usage values.

### Content compression (Brotli / gzip / deflate / Zstandard)

ZHOST can compress eligible, **unencoded** HTTP responses while streaming the
body to the client. Negotiation observes `Accept-Encoding` quality weights,
explicit refusals, and `identity`; `Content-Encoding` and `Vary:
Accept-Encoding` describe the selected representation. Brotli, gzip and
deflate use built-in Node.js encoders; `zstd` is enabled only on runtimes with
native Zstandard support (experimental in Node 22.15+/23.8+).

```sh
# Compression is enabled by default for eligible proxy responses
zerux-server proxy start --port 1355 --compress \
  --compression-min 1024 --compression-encodings br,zstd,gzip,deflate

# Delegate compression entirely to an Nginx front proxy or upstream app
zerux-server proxy start --port 1355 --no-compress
```

Programmatically:

```ts
const server = createProxyServer({
  getRoutes,
  proxyPort: 1355,
  compression: { enabled: true, minBytes: 1024, encodings: ['br','zstd','gzip','deflate'] },
});
```

**Reverse-proxy safety:** Existing upstream `Content-Encoding` is passed
through unchanged (not decompressed or compressed a second time). Nginx can
also compress responses at the outer boundary; it should respect the
pre-existing `Content-Encoding` and forward `Accept-Encoding` correctly. Use
`--no-compress` to make Nginx the sole compression layer. The proxy strips
invalidated body-specific headers (`Content-Length`, strong ETags, digests,
range support) when compressing. It does not compress SSE, ranges/206, HEAD,
no-body statuses, or `Cache-Control: no-transform` responses. Upstream SSE
responses receive `X-Accel-Buffering: no` and `no-transform` to help protect
real-time delivery through Nginx. Already compressed but **unacceptable**
upstream representations return HTTP 406 instead of emitting content the
client cannot decode; configure upstreams to honor the original
`Accept-Encoding` for best compatibility. Unknown-length compressible streams
may still be compressed even if ultimately smaller than `minBytes`.

**Security:** Direct protocol gateway endpoints are private by default. If
Nginx or another front proxy forwards MCP/RPC to ZHOST, explicitly configure
`authorize(req)`; merely connecting from 127.0.0.1 is not a substitute for
user authentication. ZHOST denies forwarded requests without such an
allow-rule when recognized forwarding headers are present.

### Forwarded-response compression

HTTP responses can use streaming Brotli, gzip, deflate and Zstandard (where
Node exposes a Zstandard encoder), negotiated with `Accept-Encoding`.
Configure `compression: { enabled: true, minBytes: 1024, brotliQuality: 4 }`
on `createProxyServer()`, or set `compression: { enabled: false }` if your edge
server already compresses responses. The proxy avoids double-compression and
never transforms SSE, Connect/gRPC, range, or `Cache-Control: no-transform`
responses. Zstandard is only selected when the Node runtime supports it.

## Proxy cache control

Response caching is **off by default**, independent of ZHOST's streaming compression. To opt in:

```bash
zerux-server proxy start --port 1355 --cache \
  --cache-max-entries 256 --cache-max-entry-bytes 1048576 \
  --cache-max-total-bytes 33554432 --cache-max-age 300
```

Or set `createProxyServer({ getRoutes, proxyPort, cache: { enabled: true } })`. This exposes `proxy.responseCache.stats()`, `.invalidate(hostname, pathname?)`, and `.clear()` for explicit invalidation. The proxy caches only HTTP 200 GET responses with explicit `Cache-Control: public, max-age=...` or `s-maxage`, no cookies/authentication, no `Set-Cookie`, no `Vary` or upstream `Content-Encoding`, and no range, protocol stream or private/no-store response. Storage uses bounded process memory, not disk; entries expire and are separated by route generation. Cached identity bodies are compressed per client on delivery, so a gzip client and a Brotli client are never served the wrong encoding. `Age` reflects storage time.

Cache invalidation removes entries retained at that moment. The optional
`pathname` argument matches the exact stored request URL, including query bytes
in the serving proxy. This legacy memory cache does not fence an upstream
capture already in flight, which may insert after `.invalidate()` or `.clear()`.
It provides neither distributed invalidation nor durable storage.

**Nginx deployment:** You can leave ZHOST compression disabled with `--no-compress` and use Nginx `gzip`/branch/v0.0.4/`brotli` instead. Already-compressed upstream responses always pass through unchanged. Do not configure both layers to compress the same response intentionally. SSE and Connect streams are neither buffered in ZHOST's cache nor compressed by ZHOST; configure Nginx `proxy_buffering off` for SSE as needed. Content-length, ETag, Vary and Accept-Encoding safety is handled by the proxy's compression layer; application-level cache policies remain authoritative.

### Reloading the development HTTP server

Development reloads now replace the active request handler without unbinding the application's HTTP port. In-flight requests retain their old handler, new requests use the new handler, and a failed `watchFunc` leaves the previous handler serving. This is local development lifecycle behavior, not a guarantee of zero-downtime production deployment.

## Application-owned transport registration

`zhost/protocols` exports `createTransportRegistry`,
`createHttpTransportAdapter`, `createWebSocketTransportAdapter` and
`transportCapabilityMatrix`. Registration is inert and separate from CLI/process
supervision. Factories are trusted executable code registered explicitly by the
host; no packages are scanned. Unknown adapter names fail with
`TRANSPORT_UNSUPPORTED`. Native gRPC registration is deliberately unavailable;
the existing HTTP/2 proxy forwards gRPC frames to a real upstream implementation.

```js
import {
  createTransportRegistry, createProtocolGateway, createHttpTransportAdapter,
} from 'zhost/protocols';

// Called by the owning application with its real authentication policy.
export async function openRpc(authorize) {
  const gateway = createProtocolGateway({ authorize: () => true });
  gateway.rpc.register('app.ping', () => ({ ok: true }));
  const http = createHttpTransportAdapter({ gateway, authorize });
  const transports = createTransportRegistry();
  transports.registerAdapter('http', () => http);
  transports.register({
    id: 'api', type: 'http', bind: { host: '127.0.0.1', port: 0 },
    limits: { maxMessageBytes: 65536, maxConcurrent: 16, timeoutMs: 5000 },
  });
  await transports.start();
  return { address: http.address(), health: () => transports.health(),
    close: () => transports.stop() };
}
```

The HTTP adapter requires an authorization callback, even on loopback. It owns
and closes its gateway. For granular permissions, also provide the gateway's
`authorizeEndpoint(req, { kind, path }, { signal, remoteAddress, trustedProxy })`;
both policies must return exactly `true`. Authentication does not grant method,
tool, resource or tenant permissions: handlers enforce those separately. Legacy
direct gateways retain their localhost-only default. Forwarding headers never
automatically establish identity; handler context removes them unless the exact
direct peer is in `trustedProxies`. Host authorization receives the original
request and must implement any verified proxy policy itself. Origin checks are
additional to authentication.

| Implementation        | Request                  | Server stream | Bidirectional      | Boundary                                                            |
| --------------------- | ------------------------ | ------------- | ------------------ | ------------------------------------------------------------------- |
| HTTP adapter          | yes                      | yes           | no                 | Required host authorization, gateway endpoint policy                |
| WebSocket adapter     | messages                 | yes           | yes                | Required upgrade authorization; handler owns per-message permission |
| SSE `/events`         | GET                      | yes           | no                 | Gateway authorization; bounded clients/replay                       |
| JSON-RPC `/rpc`       | POST/batch/notifications | no            | no                 | Gateway authorization; sequential batch up to 64                    |
| MCP `/mcp`            | stateless JSON POST      | no            | no                 | Tools/resources core subset, default version `2025-11-25`           |
| Connect               | unary JSON               | framed JSON   | framed JSON subset | No Protobuf, compression or complete Connect/gRPC compatibility     |
| tRPC                  | JSON bridge              | no            | no                 | No complete official tRPC wire protocol                             |
| gRPC                  | forwarded                | forwarded     | forwarded          | HTTP/2 upstream owns native RPC/authentication                      |
| WebTransport / HTTP/3 | external                 | external      | external           | Explicit external QUIC terminator/session handoff                   |

MCP is not complete Streamable HTTP: no session negotiation, resumable MCP SSE,
subscriptions, sampling or elicitation. The generic `/events` stream is separate.
See the [MCP transport specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2026-07-28/basic/transports/streamable-http.mdx),
[Connect framing protocol](https://connectrpc.com/docs/protocol/) and
[ws API](https://github.com/websockets/ws/blob/master/doc/ws.md).
Legacy `transportCapabilities()` booleans describe configured proxy/terminator
availability; the new matrix describes actual implementations, not native gRPC
or QUIC support.

Bindings require numeric IPs. Fixed TCP port collisions are rejected
conservatively across hosts before starting; port zero permits separate ephemeral
listeners. Duplicate gateway paths also reject before the HTTP listener opens.
Start runs in registration order, checks readiness and rolls back acquired
adapters in reverse order on failure. Health records contain only id/type/state
and readiness; private failure causes remain on `TransportLifecycleError.cause`.
Registrations can be removed with their exact returned handle before startup.
A failed/stopped registry is terminal; create a fresh generation for replacement.

Limits default to 1 MiB/message, 32 concurrent operations and 30 seconds; caps are
16 MiB, 1024 and 120 seconds. JSON readers bound bytes and deadlines, response
serialization is bounded, and gateway slots remain occupied until disconnected
handlers actually settle. Connect input/output has additional message/total-byte
bounds. Signals reach RPC/Connect/tRPC and MCP tool handlers. SSE retains its
own replay/client limits and occupies a gateway slot until disconnect; request
deadlines do not expire an established SSE subscription.

WebSocket adapters require `path`, `authorize`, and `onMessage(data, context)`;
`context` supplies a cooperative signal, binary flag, direct peer address and
bounded async `send`. Browser origins require explicit `allowedOrigins`. The ws
library caps the aggregate size of fragmented messages; compression is disabled.
Connections, concurrent authorization/message jobs and buffered sends are bounded.
Only one message handler runs per connection: overlapping messages close with
1013 rather than building a queue. Oversized frames close with 1009. Deadline or
disconnect aborts the current handler; errors terminate the connection and only
reach the optional private `onError` callback.

Stop closes admission, aborts handlers, ends SSE/terminates WebSockets, destroys
owned sockets/listeners and waits for actual pending handler settlement. Adapter
`stop` and registered `onDispose` cleanup must be idempotent; partial start must
register cleanup immediately. Startup deadlines signal cancellation, not forced
JavaScript interruption. Uncooperative custom adapters/handlers can delay
startup or shutdown; no timeout claims to kill their work. No import starts
telemetry, listeners, subprocesses or external integrations.

## Proxy resource budgets and overload

Configure budgets for a measured application workload; existing global concurrency
(default 100000) and rate/body defaults are preserved. The following example owns
only a disposable local listener and never launches an application:

```js
import { createProxyServer } from 'zhost';

export async function openProxy(getRoutes) {
  const proxy = createProxyServer({ getRoutes, proxyPort: 0,
    limits: { maxConnections: 256, maxActiveRequests: 128,
      maxActiveStreams: 32, maxActivePerClient: 16, maxBodyBytes: 1048576 } });
  await new Promise((resolve, reject) => {
    proxy.once('error', reject);
    proxy.listen(0, '127.0.0.1', resolve);
  });
  return { address: proxy.address(), stats: () => proxy.getAdmissionStatus(),
    close: () => proxy.connectionTracker.drain({ timeoutMs: 5000 }) };
}
```

`maxConnections` bounds incoming TCP connections, including idle sockets, using
[Node's listener ceiling](https://nodejs.org/api/net.html#servermaxconnections).
It is opt-in and closes excess sockets before HTTP admission; no HTTP response
is guaranteed for that case. `maxActiveRequests` counts admitted HTTP requests
(including SSE, downloads and HTTP/2 requests) and upgraded WebSockets together.
`maxActiveStreams` is an optional **WebSocket upgrade** ceiling inside that global
budget; setting it lower reserves headroom for HTTP. It does not classify
arbitrary HTTP response streams. `maxActivePerClient` counts both by direct peer
IP, including loopback proxies; forwarded headers cannot bypass it. Behind a
shared trusted proxy this deliberately groups all its requests. Apply verified
identity/tenant rate limits at the authenticated application boundary separately.
The security package's atomic limiter contract provides distributed accounting;
these admission limits are process-local and are not a distributed fallback.

All three new ceilings accept integers 1–100000. Per-peer concurrency accounting
retains at most 4096 active peers and refuses new peers at capacity; live entries
are never evicted. Capacity refusals use 503, token-bucket rate refusals use 429,
and both carry `Retry-After: 1`. There is no unbounded waiting queue, FIFO guarantee
or automatic retry. Optional peer caps limit monopolization but do not promise
fairness between tenants sharing an IP.

`getAdmissionStatus()` returns fixed fields: active requests/upgraded streams,
tracked peer count, accepted total, rate refusals and capacity refusals. It exposes
no peer labels, URLs, credentials or bodies. The existing HTTP telemetry remains
capped at 128 route labels plus the outside-routes bucket; backend completion
samples remain capped at 512 per backend with bounded recent windows.

Uploads and downloads retain Node stream piping/backpressure. Both compressed
and identity response helpers destroy their source when the downstream closes;
SSE is neither buffered in the response cache nor recompressed. Upgraded tunnels
retain bidirectional piping and peer cancellation. Cache eligibility continues
to exclude authenticated/cookie/private/no-store traffic. Hop-by-hop and forwarding
header filtering is unchanged. Upstream HTTP inactivity and WebSocket handshake
timeouts remain 30 seconds; established streams have no new forced lifetime.

A backend is selected once per request. A failure after any partial POST delivery
ends that attempt; a changed route or healthy replacement is used only by later
requests. The proxy never replays the partially delivered operation. Existing
health isolation, rolling handover and persisted restart budgets are preserved.
Drain stops accepting connections, closes idle HTTP sockets when supported, waits
for work and destroys remaining sockets at its deadline. Deadlines are integer
0–300000ms (default 10000); repeated drain calls share the first result. Socket
closure does not establish that an upstream business operation rolled back.

The focused `test/proxy-resilience.test.ts` workload sends 24 concurrent loopback
GETs to a 40ms-delayed backend with admission 4, identity responses and no HTTP
agent reuse. It reports runtime, elapsed time, status counts and peak upstream
concurrency, and asserts zero retained connection/admission counts. Separate cases
exercise paused downstreams, aborted slow uploads, unavailable backends and a
partially delivered POST. This is a bounded correctness workload, not evidence of
a production throughput improvement or live deployment acceptance.

Gateway RPC/Connect/MCP contexts now include an absolute `deadline` (Unix ms).
Optional `x-zerux-deadline` request metadata must be a safe nonnegative integer:
malformed values reject with 400, elapsed deadlines with 408. The gateway takes
the minimum of this value and its own configured timeout and aborts the handler's
signal at that limit. It never trusts this header for authorization or extends host
budgets. The framework service client uses this convention across its declared
JSON endpoints; this does not introduce a native gRPC deadline protocol.

Native WebSocket message contexts also expose a local absolute `deadline` for each
message handler, capped by that adapter's message timeout. It does not turn a
handshake deadline into an established connection lifetime.

### Explicit compression ownership

`zhost/compression` exposes `createCompressionOwner` for application response
integrations. It delegates to the existing proxy encoder with bounded native
encoder admission (default eight), identity/406 overload behavior and fixed-field
measurement callbacks. Close cancels source/response pipelines and awaits native
encoder close. It does not change an existing proxy's admission limits or install
compression globally. See the performance package's response integration for
private cache policy and single-layer ownership. HEAD and bodyless statuses close
the unused source without consuming it; identity streaming errors terminate the
downstream response.

For an owned development server, `dev.watchIgnoredPaths` is an optional list of
relative directories excluded before source traversal. `createDevelopmentWatchIgnore`
validates and snapshots up to 128 bounded relative paths; subscriber ignores never
change another app’s mutable configuration. The framework forwards its stricter
`server.watchIgnoredPaths` policy together with its runtime directory.

`startServer({config:{server:{portless:false}}, app: ...})` starts the direct
loopback HTTP application without starting the shared portless proxy or registering
proxy aliases. The default remains true. This setting does not change devtools
host checks or grant access to an existing proxy's PID/state files.

Proxy/fleet/control-state readers validate type, size, owner and private-mode
requirements against the same open inode whose bytes they consume. Reads are
bounded and reject leaf symlinks. Private parent ownership and the existing
operation/route locks still belong to the host; these helpers do not create a
filesystem sandbox or an atomic compare-and-delete primitive.

### Ownership during fleet sampling and protocol shutdown

Autoscaler `stop()` clears future timer ticks and fences later scale requests;
it does not return a drain promise for a sample already running. Keep borrowed
fleet/metric dependencies alive through their own actual settlement. Transport
registry `stop()` instead awaits acquired adapter stop/cleanup promises in reverse
order. HTTP/WebSocket adapters retain pending handler jobs until actual settlement;
aborting a signal cannot force arbitrary application JavaScript to stop.

Private local control descriptors contain secret tokens and native process
identities. Keep them in the host-owned private state directory and out of public
configuration and diagnostic responses. A client-side control timeout closes its
socket; it does not prove rollback of a server-side operation.


## Implementation sources

- [packages/zhost/README.md](https://github.com/zeruxjs/zeruxjs/blob/version/v0.0.4/packages/zhost/README.md)
- [packages/zhost/package.json](https://github.com/zeruxjs/zeruxjs/blob/version/v0.0.4/packages/zhost/package.json)
