| Safe Haskell | None |
|---|---|
| Language | GHC2021 |
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'sMountRouter. A binding carries a mount's complete ecosystem wiring: its router and serve dependencies. The web layer is closed over the agnosticRouteActionvocabulary 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 (seedocs/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 generic404 Not Foundintext/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 anRouteAction: 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.md → Middleware): 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
- data ServerConfig = ServerConfig {
- scPort :: Int
- scMounts :: [MountBinding]
- scDrain :: DrainSignal
- scDrainTimeout :: ShutdownDrainTimeout
- scCheckReady :: IO Bool
- scCheckLive :: IO Bool
- scOnException :: Maybe Request -> SomeException -> IO ()
- mkServerConfig :: [MountBinding] -> ServerConfig
- defaultPort :: Int
- data MountBinding = MountBinding {
- bindingPrefix :: NonEmpty Text
- bindingRouter :: MountRouter
- bindingPackumentDeps :: PackumentDeps
- bindingPublishDeps :: Maybe PublishDeps
- application :: ServerConfig -> Env -> Application
- tracedApplication :: ServerConfig -> Env -> IO Application
- runWarp :: ServerConfig -> (ServerConfig -> IO Application) -> IO ()
- raceServerAgainstLoop :: MonadUnliftIO m => m () -> m () -> m ()
- probeApplication :: DrainSignal -> IO Bool -> IO Bool -> Application
- perimeterGuard :: (RequestFault -> IO ()) -> (response -> IO ResponseReceived) -> response -> ((response -> IO ResponseReceived) -> IO ResponseReceived) -> IO ResponseReceived
- data DrainSignal
- newDrainSignal :: IO DrainSignal
- neverDraining :: DrainSignal
- beginDrain :: DrainSignal -> IO ()
- isDraining :: DrainSignal -> IO Bool
- newtype ShutdownDrainTimeout = ShutdownDrainTimeout Int
- defaultShutdownDrainTimeout :: ShutdownDrainTimeout
- data InteractiveHalt = InteractiveHalt {
- haltOnInteractive :: IO Bool
- awaitHaltSignal :: IO ()
- halt :: IO ()
- defaultInteractiveHalt :: InteractiveHalt
- withInteractiveHalt :: InteractiveHalt -> IO a -> IO a
- serverMiddleware :: ServerConfig -> Middleware
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
| |
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
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 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).
Constructors
| ShutdownDrainTimeout Int |
Instances
| Show ShutdownDrainTimeout Source # | |
Defined in Ecluse.Runtime.Server.Drain Methods showsPrec :: Int -> ShutdownDrainTimeout -> ShowS # show :: ShutdownDrainTimeout -> String # showList :: [ShutdownDrainTimeout] -> ShowS # | |
| Eq ShutdownDrainTimeout Source # | |
Defined in Ecluse.Runtime.Server.Drain Methods (==) :: ShutdownDrainTimeout -> ShutdownDrainTimeout -> Bool # (/=) :: ShutdownDrainTimeout -> ShutdownDrainTimeout -> Bool # | |
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
| |
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.