ecluse
Safe HaskellNone
LanguageGHC2021

Ecluse.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 Classifier. A binding carries a mount's complete ecosystem wiring — its classifier, its packument-serve dependencies, and its error MountRenderer — so the web layer is closed over the shared Route set (Ecluse.Server.Route) 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/hosting.mdDispatch).

Responses split into two tiers:

  • Above the mounts — neutral, 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 renderer. The classified Route renders through that mount's MountRenderer, in the ecosystem's own error surface: /-/ping is answered locally with 200 {}, /-/v1/search is 501 (search is not an install path), an unrecognised in-mount path is 404 (deny by default), and the package/artifact routes (Packument, Tarball) are recognised but, without serve dependencies wired, return an explicit 501 Not Implemented rather than a fabricated success — their fetch → rules → serve pipeline lives outside this module.

Cross-cutting concerns are applied as middleware composed around the Application (see docs/architecture/web-layer.mdMiddleware): a defensive request-body size cap, correct client-IP recovery behind a load balancer, and a request timeout. Dispatch builds a per-request RequestCtx — the composition-root Env 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 composition root 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, the served mount bindings, and the request-body cap. Backend selection is a composition-root concern; this is the minimal shape the web layer needs to route.

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.

  • scSizeLimit :: RequestSizeLimit

    The defensive cap on request-body size.

  • scDrain :: DrainSignal

    The shared shutdown-drain flag the front door observes: once raised, the readiness probe fails (readiness) 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; runServer replaces it with a live signal it flips 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).

mkServerConfig :: [MountBinding] -> ServerConfig Source #

Build a ServerConfig over the given mount bindings, taking the default listen port (defaultPort) and request-body cap (defaultRequestSizeLimit).

The composition root supplies the bindings — each a mount's complete ecosystem wiring — and overrides the port or cap 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 Source #

A mount: a path prefix bound to a registry, carrying that registry's complete ecosystem wiring. Dispatch matches a request's leading path segments to bindingPrefix, strips them, and routes the remainder through the rest of the binding (see Ecluse.Server).

The prefix is a NonEmpty list of segments ("npm" :| [] for a /npm mount): every registry is path-mounted, so a root mount — which would force a URL change on every consumer the day a second ecosystem is added — is unrepresentable rather than merely discouraged. Bundling the classifier, serve dependencies, and renderer into one record means a mount cannot be half-wired: there is no default to fall back to.

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

runServer :: ServerConfig -> Env -> IO () Source #

Serve the proxy's HTTP front door: start warp on the ServerConfig's port with the application built over it and the composition-root Env. 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. A fresh live DrainSignal is allocated per launch and wired into both the request path (the application reads it through scDrain) and the warp shutdown handler. 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.

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. runServer allocates one per launch and flips it from the signal handler; the application it builds reads the very same signal, so the readiness probe and the going-away middleware see the drain 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 runServer.

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 runServer).

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: a defensive request-body size cap (rejecting an over-cap body with 413 once a handler reads it), correct client-IP recovery behind a load balancer (X-Forwarded-For / X-Real-IP), and a per-request timeout.

A fourth 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/hosting.md → "Graceful rollover").

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.

Request-body cap

newtype RequestSizeLimit Source #

The maximum request-body size accepted, in bytes — a defensive cap so a hostile or runaway client cannot force the proxy to buffer an unbounded body. A 'newtype' so a raw byte count is not mistaken for some other Word64.

Constructors

RequestSizeLimit Word64 

defaultRequestSizeLimit :: RequestSizeLimit Source #

The default request-body cap: 25 MiB. Generous for the metadata and small control-plane bodies the proxy accepts (artifact downloads stream the other way and are never buffered), while still bounding a hostile upload.