Ecosystem ↗

Server orchestration and local proxies

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

↗ View Markdown

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. Each target describes a published entry, not automatic framework registration.

ImportTarget
zhost
{
  "types": "./dist/index.d.ts",
  "import": "./dist/index.js"
}
zhost/protocols
{
  "types": "./dist/protocols/index.d.ts",
  "import": "./dist/protocols/index.js"
}
zhost/compression
{
  "types": "./dist/compression.d.ts",
  "import": "./dist/compression.js"
}
Runtime requirements and dependencies
{
  "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"
  }
}
Full 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/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. 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/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, Connect framing protocol and ws API. 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. 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

Implementation files for the APIs described on this page.

ZeruxJS · Open source · MIT OR Apache-2.0

Make yourself at home.

Every shortcut has a visible control too. Reading and navigation work without JavaScript.

Ctrl / ⌘ K
Search all documentation
/
Search, outside editable fields
?
This help, outside editable fields
↑ / ↓
Move through search results
Esc
Close a dialog or version menu
Progress, privacy, offline reading and controls →

Reset this learning record?

This removes course answers, exercise confirmations, bookmarks and recent pages for this docs version in this browser. Export a copy first if you want to keep it.

Search documentation

Type at least two characters.