ecluse:ecluse-runtime
Safe HaskellNone
LanguageGHC2021

Ecluse.Runtime.Server

Description

The HTTP front door: the raw wai Application, its dispatch, the meta-routes, the middleware stack, and runServer.

The proxy is a passthrough over a small, irregular URL surface, so the front door is a raw Application rather than a web framework -- matching on pathInfo keeps the encoded-slash handling and the streaming control the proxy depends on (see docs/architecture/web-layer.md). Routing is two layers:

  • Mount dispatch: match a request's leading path segments to a configured MountBinding, strip the prefix, and hand the remainder (an ecosystem-native path) to that mount's MountRouter. A binding carries a mount's complete ecosystem wiring: its router and serve dependencies. The web layer is closed over the agnostic RouteAction vocabulary and holds no ecosystem's path grammar or body shape of its own. Every registry is path-mounted (e.g. /npm); there is no root mount, so adding an ecosystem never changes an existing consumer's URLs. A mount prefix is accepted with or without a trailing slash (see docs/architecture/web-layer.md → "Multi-ecosystem mounts").

Responses split into two tiers:

  • Above the mounts, neutral and server-owned. The orchestration health probes (/livez, /readyz) are answered at the top level, and a path matching no configured mount is a generic 404 Not Found in text/plain: there is no ecosystem to shape it.
  • Within a matched mount. The mount's router (MountRouter, supplied by its ecosystem adapter) says what the request names, as an RouteAction: a route-scoped response contract existentially paired with either a pure response value or a data-plane handler that can produce only that value type.

This module holds no route knowledge of its own. It does not name a route, a path grammar, or a status: it asks the matched mount's router for an action and either responds with it or runs it under the request perimeter. Adding an ecosystem adds a router and changes nothing here.

Cross-cutting concerns are applied as middleware composed around the Application (see docs/architecture/web-layer.mdMiddleware): correct client-IP recovery behind a load balancer, and a request timeout. The request-body cap is not cross-cutting -- it is a route concern, enforced at the read site by the only body-consuming route (publish). The middleware pieces and the health probes live in Ecluse.Runtime.Server.Middleware, the graceful-shutdown drain vocabulary in Ecluse.Runtime.Server.Drain, and the local-dev quit key in Ecluse.Runtime.Server.Halt; this module composes them and re-exports their surface. Dispatch builds a per-request RequestCtx -- the request runtime (serveRuntimeOf) paired with the matched MountBinding -- and the effectful routes run in the Handler reader over it, so a handler reads its mount's wiring and the request runtime from context rather than as threaded arguments.

Synopsis

The WAI application

data ServerConfig Source #

The server's own settings -- the values the Application and runServer need that the composition-root Env does not carry: the listen port and the served mount bindings. Backend selection is a composition-root concern; this is the minimal shape the web layer needs to route. The request-body cap is not here: it is a route concern, enforced by the only body-consuming route (publish) against its own pubMaxRequestBytes.

Constructors

ServerConfig 

Fields

  • scPort :: Int

    The TCP port warp listens on.

  • scMounts :: [MountBinding]

    The mounts served, tried in order; the first whose prefix matches the request's leading segments wins. A deployment with no mounts serves nothing beyond the health probes -- every other path is the neutral 404.

  • scDrain :: DrainSignal

    The shared shutdown-drain flag the front door observes: once raised, the readiness probe fails and responses carry Connection: close (the going-away middleware), so a load balancer stops routing new traffic to this instance and clients stop reusing keep-alive sockets to it. Defaults to neverDraining; runWarp allocates a live signal per launch, builds the Application over it (through the config it hands the builder), and raises it on a shutdown signal.

  • scDrainTimeout :: ShutdownDrainTimeout

    How long the graceful drain waits for in-flight requests and in-progress artifact streams to finish before the process exits (defaultShutdownDrainTimeout).

  • scCheckReady :: IO Bool

    An additional readiness gate the composition root installs, ANDed with the drain check by /readyz. Today it is the advisory database's first-sync signal: a one-way flip per configured ecosystem, so readiness never flaps on it, and pure True (the mkServerConfig default) when no advisory bucket is configured. The listener serves regardless, since an absent advisory database only ever abstains into deny-by-default; this gates what a load balancer routes, not whether the process answers.

  • scCheckLive :: IO Bool

    The liveness check /livez answers from, beyond the listener itself. The composition root wires the mirror worker's consume-loop heartbeat here exactly when a worker runs (a mirroring deployment); the mkServerConfig default is pure True (the listener alone), so a serve-only deployment can never go unhealthy over a worker it never started.

  • scOnException :: Maybe Request -> SomeException -> IO ()

    warp's exception hook, fired for a fault that escapes to the server itself: a post-commit teardown the request perimeter rethrew, or a fault in warp's own connection handling. The composition root wires it to the process's structured logger (filtered through defaultShouldDisplayException, so routine client disconnects stay quiet); the mkServerConfig default is inert, so a bare config never surprises a test with logging.

mkServerConfig :: [MountBinding] -> ServerConfig Source #

Build a ServerConfig over the given mount bindings, taking the default listen port (defaultPort).

The composition root supplies the bindings -- each a mount's complete ecosystem wiring -- and overrides the port by record update where a deployment needs to. There is no built-in mount: an ecosystem is served only once its binding is passed here, so the web layer carries no ecosystem of its own.

defaultPort :: Int Source #

The conventional npm proxy listen port (4873), the mkServerConfig default.

data MountBinding #

Constructors

MountBinding 

Fields

application :: ServerConfig -> Env -> Application Source #

Build the proxy's WAI Application over a ServerConfig and the composition-root Env, with the middleware stack composed around it.

The bare app dispatches a request: a control-plane health probe (/livez / /readyz) is answered at the top level; otherwise the leading path segment is matched to a mount, the prefix stripped, and the remainder classified and rendered. The returned Application has the middleware applied (body cap, client-IP recovery, timeout).

tracedApplication :: ServerConfig -> Env -> IO Application Source #

Build the proxy Application with the OpenTelemetry server-span middleware wrapped outermost around application, so one server span covers the whole request (the other middlewares included). When telemetry is disabled the wrapper is id, so this is exactly application -- additive and inert off (see Ecluse.Runtime.Telemetry.Tracing). runServer serves through this; a caller embedding the proxy that wants the request trace builds its application here rather than through the bare application.

Running the server

runWarp :: ServerConfig -> (ServerConfig -> IO Application) -> IO () Source #

Serve the proxy's HTTP front door: allocate the launch's live DrainSignal, build the Application by handing the supplied builder a ServerConfig whose scDrain is that signal, and start warp on the config's port with it. The ServerConfig -- in particular its mount bindings (scMounts), each a mount's complete ecosystem wiring -- is supplied by the composition root, which is where the served ecosystems are mounted (see Ecluse).

Graceful shutdown. The fresh live DrainSignal allocated per launch is wired into both the request path and the warp shutdown handler: the Application builder is invoked with a ServerConfig carrying that signal, so the readiness probe and the going-away middleware read the very drain the handler raises. On SIGTERM or SIGINT the handler raises the drain -- so the readiness probe begins failing and responses gain Connection: close -- then closes the listen socket, which puts warp into graceful-shutdown mode: it stops accepting new connections and waits for in-flight requests and in-progress artifact streams to finish before the process exits, bounded by scDrainTimeout. The handler is a CatchOnce, so a second signal during the drain hard-stops the server rather than being swallowed.

Local-dev quit key. The whole run is wrapped in withInteractiveHalt, which -- only when attached to an interactive terminal -- arms a watcher that forces an immediate halt on Ctrl-D (end of standard input), bypassing the drain like a second Ctrl-C. Outside a TTY (production) no watcher is installed and this changes nothing.

raceServerAgainstLoop :: MonadUnliftIO m => m () -> m () -> m () Source #

Race a server arm (the first argument) against a never-returning background loop (the second): the shutdown shape the single-process composition roots share -- the proxy racing its HTTP server against the mirror worker, and the pilot racing its probe server against the OSV export loop.

Choosing race_ over concurrently_ is the shutdown invariant. The background loop never returns, so a concurrently_ would keep waiting on it after the server has gracefully drained and returned, leaving the surrounding telemetry and resource brackets un-unwound: no exporter flush, the process hanging until a second signal or the orchestrator's kill. race_ lets the server's graceful return cancel the loop and unwind those brackets (flush and exit cleanly), while a fault thrown by either arm still propagates (race_ re-raises it) so a genuine failure fails the process up rather than being swallowed.

probeApplication :: DrainSignal -> IO Bool -> IO Bool -> Application Source #

The control-plane health probes, answered above any mount: /livez from the injected liveness check (the worker-heartbeat arm folded in by the caller), /readyz from the drain signal ANDed with the composition root's startup gate, and any other unmounted path as the neutral 404.

The typed request perimeter

perimeterGuard Source #

Arguments

:: (RequestFault -> IO ())

Observe a classified pre-commit fault (the metric and the audit line).

-> (response -> IO ResponseReceived)

The route-scoped response continuation.

-> response

The route's declared neutral pre-commit fallback.

-> ((response -> IO ResponseReceived) -> IO ResponseReceived)

The route's handler, discharged to IO, awaiting the tracked respond.

-> IO ResponseReceived 

The typed request perimeter over one effectful route: run the handler with a commit-tracking respond, catching only synchronous escapes (asynchronous cancellation is not caught and tears the request down like any thread). The handlers report every routine failure as a value, so what arrives here is an escape from some dependency's typed contract.

Pre-commit, the escape is classified (classifyEscape), handed to the injected observation channel (the composition wires the bounded ecluse.serve.perimeter.faults metric and the audit log line), and answered with the route's declared neutral 500 -- no fault detail ever reaches the client. Post-commit -- the wrapped respond has already begun the response -- there is no second response to give: the escape rethrows, warp tears the connection down, and the scOnException hook logs it. Exported for its spec; serve wires it per request.

Graceful shutdown

data DrainSignal Source #

The shared shutdown-drain flag the front door observes during a graceful rollover, as a small handle (a reader plus a one-way raise) rather than a bare TVar -- so the same field can hold either a live, flip-once signal (newDrainSignal) or the inert neverDraining constant the socket-free tests assemble against, and nothing downstream can lower it back. It is raised once, on a shutdown signal, and read on every request by the readiness probe and the going-away middleware.

newDrainSignal :: IO DrainSignal Source #

Allocate a live, lowered shutdown-drain signal backed by a TVar. runWarp allocates one per launch, hands it to the application builder through the ServerConfig it passes, and flips it from the signal handler, so the readiness probe and the going-away middleware read the very same signal the instant the handler raises it.

neverDraining :: DrainSignal Source #

The inert drain signal: permanently lowered, raising it is a no-op. The mkServerConfig default, so an application assembled for a socket-free test (and one driven without ever entering shutdown) reports ready and adds no going-away header. A real launch overrides it with newDrainSignal in runWarp.

beginDrain :: DrainSignal -> IO () Source #

Raise a drain signal -- the one-way transition into draining. Idempotent.

isDraining :: DrainSignal -> IO Bool Source #

Read whether a drain signal is raised.

newtype ShutdownDrainTimeout Source #

The bound on the graceful drain: how many seconds the server waits for in-flight requests and in-progress artifact streams to finish after it stops accepting new connections, before the process exits regardless. A newtype so a raw seconds count is not mistaken for some other Int, and so a non-positive value cannot be passed where a positive timeout is meant (see runWarp).

defaultShutdownDrainTimeout :: ShutdownDrainTimeout Source #

The default graceful-drain bound: 30 seconds. Long enough for an in-flight metadata fetch or a moderate artifact stream to complete during a rolling deploy, short enough that a stuck request cannot pin the old instance indefinitely.

Local-dev immediate halt

data InteractiveHalt Source #

The local-development immediate-halt wiring, as three injection points so its logic is exercised without a real terminal. It exists only to give an interactive session a "quit now" key: when the server is attached to a TTY, closing standard input (Ctrl-D) forces an immediate process exit, aborting any in-progress drain -- the same hard-stop a second Ctrl-C gives, but on the dev's deliberate signal.

It is inert outside an interactive terminal: in production standard input is a non-TTY or closed, haltOnInteractive returns False, and no watcher is installed, so the signal-driven graceful lifecycle is completely untouched. The TTY guard is what enforces that zero-production-impact contract (see withInteractiveHalt).

Constructors

InteractiveHalt 

Fields

  • haltOnInteractive :: IO Bool

    Whether to arm the halt at all -- the production guard. The real wiring is "is standard input a terminal?", so a non-interactive process never installs the watcher.

  • awaitHaltSignal :: IO ()

    Block until the dev's halt signal. The real wiring reads standard input until end-of-input (Ctrl-D); it returns when the watcher should fire.

  • halt :: IO ()

    The halt itself: terminate the process immediately, bypassing the drain wait. The real wiring is a direct _exit (exitImmediately), matching the second-Ctrl-C hard stop.

defaultInteractiveHalt :: InteractiveHalt Source #

The real local-dev halt: armed only when standard input is a terminal (hIsTerminalDevice), fired by end-of-input on standard input (Ctrl-D), and halting via exitImmediately -- an immediate _exit that bypasses the graceful drain, mirroring a second Ctrl-C. The exit status (130) is the conventional "terminated from the terminal" code.

withInteractiveHalt :: InteractiveHalt -> IO a -> IO a Source #

Run an action with the local-dev immediate-halt watcher armed __only when interactive__. If haltOnInteractive is True, a watcher runs alongside the action for exactly its lifetime (withAsync, so it is torn down when the action returns or is cancelled -- it never lingers); the watcher blocks on awaitHaltSignal and, when that returns, runs halt. If False -- the production case -- the action runs alone, with no watcher and no extra thread, so nothing about the graceful lifecycle changes.

Middleware

serverMiddleware :: ServerConfig -> Middleware Source #

The cross-cutting middleware stack composed around the proxy Application: correct client-IP recovery behind a load balancer (X-Forwarded-For / X-Real-IP), and a per-request timeout. The pieces live in Ecluse.Runtime.Server.Middleware; this composes them over the ServerConfig.

The request-body cap is not a middleware. Only one route (publish) consumes a request body, and it bounds it at the source as a value: a declared Content-Length over the cap fails closed before a byte is read, and a chunked body is bounded by a counted read (boundedRead), each answered as the route's own 413. A body-cap middleware would instead have to wrap the reader and throw across the request perimeter (untracked control flow), so the bound lives at the read site (Publish) rather than here.

A third middleware, the going-away header, is active only during a graceful drain: while the ServerConfig's DrainSignal is raised it stamps Connection: close on every response so an HTTP/1.1 keep-alive pool (a client's, or a service mesh's connection pool) does not reuse a socket on an instance that is shutting down -- the cause of the 503-on-rollover this guards against (see docs/architecture/web-layer.md → "Graceful shutdown").

Two wai-extra middlewares are deliberately not used. Autohead answers a HEAD by running the GET handler and discarding the body, which on a tarball route would open the upstream and stream a whole artifact to nowhere; instead a HEAD on the tarball or packument route is handled explicitly (in serve), gating exactly as the GET path does but suppressing the body -- the tarball probing the upstream as a HEAD so a bodiless HEAD can never trigger a full-artifact upstream fetch, the packument emitting the same status and headers as the GET with the locally-built body withheld. Gzip would re-compress already compressed artifacts and fight the streaming backpressure the serve path relies on.