| Safe Haskell | None |
|---|---|
| Language | GHC2021 |
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'sClassifier. A binding carries a mount's complete ecosystem wiring — its classifier, its packument-serve dependencies, and its errorMountRenderer— so the web layer is closed over the sharedRouteset (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 (seedocs/architecture/hosting.md→ Dispatch).
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 generic404 Not Foundintext/plain— there is no ecosystem to shape it. - Within a matched mount — the mount's renderer. The classified
Routerenders through that mount'sMountRenderer, in the ecosystem's own error surface:/-/pingis answered locally with200 {},/-/v1/searchis501(search is not an install path), an unrecognised in-mount path is404(deny by default), and the package/artifact routes (Packument,Tarball) are recognised but, without serve dependencies wired, return an explicit501 Not Implementedrather 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.md → Middleware): 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
- data ServerConfig = ServerConfig {}
- mkServerConfig :: [MountBinding] -> ServerConfig
- defaultPort :: Int
- data MountBinding = MountBinding {}
- application :: ServerConfig -> Env -> Application
- tracedApplication :: ServerConfig -> Env -> IO Application
- runServer :: ServerConfig -> Env -> IO ()
- 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
- newtype RequestSizeLimit = RequestSizeLimit Word64
- defaultRequestSizeLimit :: RequestSizeLimit
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
| |
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.
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).
Constructors
| ShutdownDrainTimeout Int |
Instances
| Show ShutdownDrainTimeout Source # | |
Defined in Ecluse.Server Methods showsPrec :: Int -> ShutdownDrainTimeout -> ShowS # show :: ShutdownDrainTimeout -> String # showList :: [ShutdownDrainTimeout] -> ShowS # | |
| Eq ShutdownDrainTimeout Source # | |
Defined in Ecluse.Server 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: 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 |
Instances
| Show RequestSizeLimit Source # | |
Defined in Ecluse.Server Methods showsPrec :: Int -> RequestSizeLimit -> ShowS # show :: RequestSizeLimit -> String # showList :: [RequestSizeLimit] -> ShowS # | |
| Eq RequestSizeLimit Source # | |
Defined in Ecluse.Server Methods (==) :: RequestSizeLimit -> RequestSizeLimit -> Bool # (/=) :: RequestSizeLimit -> RequestSizeLimit -> Bool # | |
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.