Help us shape the future of Go! Take the 2026 Go Developer Survey

serverkit

package
v1.0.14 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 2, 2026 License: Apache-2.0 Imports: 49 Imported by: 0

Documentation

Overview

Package serverkit runs an already-composed forge server: HTTP/Connect listener (HTTP/1.1, HTTP/2, h2c), healthz/readyz probes, the HTTP edge (CORS / security-headers / request-id), worker supervisor, operator manager, and graceful shutdown sequence.

Why a library

Earlier forge versions emitted a ~520-line cmd/server.go per project, 95% of which was uniform across every project (signal handling, listener bind, readiness flip, worker fan-out, shutdown ordering). serverkit absorbs those uniform sections so they get one test suite and can evolve without touching every downstream project's checkout.

serverkit takes COMPOSED INPUTS and owns only the uniform lifecycle. It knows nothing about service SELECTION: which services are mounted, which workers/operators run, and how the interceptor chain was built all happen ABOVE serverkit, in the project's own serve.go. serverkit receives the finished handler (or the mux), the already-selected Workers/Operators, and an OnShutdown closure.

Alongside Run, serverkit offers the composition STEPS that every serve.go performs identically — Boot, AutoMigrate, RecordMounted, AddWorkers, RequireComplete, RESTHandler (see compose.go and rest.go). They are what let serve.go be short enough to read as a list of decisions, and what let improvements to them reach existing projects through a dependency bump rather than a re-scaffold of a file the user owns.

Usage in the composition root

cmd/<bin>/cmd/serve.go is the project's SCAFFOLD-ONCE composition root: forge writes it once and the application owns it from then on. It holds the DECISIONS — the fail-closed auth posture, the interceptor order, the payload caps, the CORS policy, the readiness set, the teardown — while the steps that look the same in every project call into here:

skCfg, _ := serverkitConfig(cfg)      // project-typed → vendor-neutral, Normalize()d
logger, mux := serverkit.Boot(skCfg)  // logger + slog.SetDefault + mux
_ = serverkit.AutoMigrate(skCfg, logger, pkgapp.AutoMigrate)

// … the owner's decisions: auth posture, chain slots, payload caps …
chain, _ := serverkit.NewChain(serverkit.ChainDeps{
    Auth: serverkit.AuthFor(serverkit.AudienceUser, authInterceptor), …})
opts := chain.HandlerOptions()

srv := serverkit.Server{Mux: mux, Chain: chain, Logger: logger, …}
srv.AddReadyCheck(serverkit.DBReadyCheck("database", infra.DB))
srv.OnShutdown = func(context.Context) error { return infra.DB.Close() }

mounted := spec.Mount(components, srv.Mux, cfg, logger, opts...)
srv.RecordMounted(mounted)
if rest := serverkit.RESTHandler(srv.Mux, mounted, logger); rest != nil {
    srv.Handler = rest
}
srv.AddWorkers(spec.Workers(components))
if spec.RequireComplete {
    if err := srv.RequireComplete(app.Inventory); err != nil { return err }
}
return serverkit.Run(ctx, skCfg, srv)

Those helpers (compose.go) are deliberately SEPARATE steps rather than one Compose(...) call: the composition root has to be able to reorder and replace them — migrate after mounting, skip the readiness pool, wrap the mux — and a step usable in only one position is a template with extra syntax, not a library.

The Server value

Server carries everything serverkit runs: the composed Handler, the selected Workers/Operators, the RunOperators manager entry point, an OnShutdown teardown closure, and the project's edge-middleware factories (their concrete implementations live in the project's pkg/middleware, so they stay fields rather than collapsing to Config). Config still owns the GATING for the edge layers (CORSOrigins, SecurityHeaders, Environment).

Components: Chain and Listener

The interceptor chain and the HTTP listener are components — structs built from their Deps — so a process can hold more than one of each:

  • NewChain(ChainDeps{Auth, …}) is the ordered interceptor stack and the payload caps. Every slot is defaulted except Auth, which is required; an Omit constant (NoAudit, NoTrace, …) drops a default. Auth is an Authenticator: an interceptor plus the audience it admits. The Deadline slot (a Deadline: per-RPC budgets and a slow-call watchdog) runs right before Auth, so the RPC's deadline bounds authentication.
  • NewListener(ListenerDeps{Config, Chain, Routes, …}) is one address with its own TLS, timeouts, HTTP/2 stream cap and Streams registry. It mounts its Routes with its Chain and refuses to start when a mounted proto service declares an audience (`(forge.v1.service).audience`, default "user") other than the chain's. Its lifecycle is Run, Drain, Close and Ready.

Run is one Listener plus the process policy around it: readiness, the pre-stop pause, workers, operators, pprof, teardown and telemetry.

Lifecycle

Run owns the uniform lifecycle:

  1. Logger init (Server.Logger, or built from Config when nil).
  2. A tiny top mux routes /healthz + /readyz to serverkit's own probes (IN FRONT of the edge wrap, so probes are never behind CORS/auth) and everything else to Server.Handler.
  3. CORS, security-headers and request-id are layered over that top mux from Config gating + the Server factory fields. The listener speaks HTTP/1.1, HTTP/2 over TLS and h2c (net/http's own, so its streams drain on shutdown).
  4. Listener binds, readiness flips true.
  5. Workers start; the operator manager starts when len(Operators) > 0, which is the caller's decision and the only one.
  6. SIGINT/SIGTERM → readiness flips to "draining" → pre-stop sleep → the listener drains (Listener.Drain: stop accepting, unary requests finish, long-lived streams end soft-then-hard) → workers stop → operators stop → Server.OnShutdown → pprof shuts down → telemetry flushes.

Long-lived streams

A stream that never finishes on its own — a server-streaming RPC, an SSE response, a websocket — would hold http.Server.Shutdown for the whole ShutdownTimeout and then be cut without a status. The listener owns a Streams registry (streams.go) that ends them inside the budget instead, with cause svcerr.ErrServerDraining: at once when the drain begins, or — for a stream that asked for serverkit.Draining(ctx) to say goodbye — when it returns or at the deadline minus Config.DrainReserve. Mark an SSE or websocket route with serverkit.LongLived; a websocket handler closes with 1012 from serverkit.AfterDrain, and one that ignores the drain has its hijacked connection closed for it, so its cleanup still runs.

Without Run

Every piece of the sequence is exported for a main that runs its own listener — or several: Listener (the whole listener component), Drainer (readiness gate, pre-stop, shutdown budget, /readyz), and, below a Listener, NewHTTPServer (an http.Server with its Streams installed), Streams, LongLived and DrainHTTP (a listener's drain). A Listener has the lifecycle lifecyclekit.Supervise runs, so a main with several listeners hands them to it with the Drainer as its policy.

Worker shutdown contract

Each worker runs in its own goroutine with a per-worker context derived from the run lifecycle. On SIGINT/SIGTERM that context is cancelled immediately — before the pre-stop drain sleep — so long-running cycles get the full shutdown window to unwind; on a fatal serve error it is cancelled when the worker-stop phase begins. Workers implementing the optional ContextWorker interface run via RunContext(ctx), the preferred ctx-aware lifecycle; all other workers run via the legacy Start(ctx) with the same per-worker context. The supervisor waits for every worker goroutine to return, then calls each worker's Stop bounded by Config.ShutdownTimeout. See ContextWorker for the full contract.

What does not belong here

The line is OWNERSHIP, not size. Anything an application author has a legitimate reason to change stays in their serve.go: the auth posture, what fills each chain slot, the payload caps, the CORS and security-header policy, which readiness checks are registered, what teardown closes, and which services/workers/operators this process runs. Absorbing any of those into serverkit would take a decision away from the person who has to answer for it.

Service SELECTION stays above serverkit too: mounting goes through the project's own typed Mount<Svc> methods, so selection is compile-time and serverkit never sees a service name it has to resolve. Per-component DI wiring stays in the generated internal/app. serverkit holds only what is identical in every forge project.

Index

Constants

View Source
const (
	// DefaultUnaryDeadline bounds a unary RPC that Deadline.Procedures does
	// not name.
	DefaultUnaryDeadline = observe.DefaultUnaryDeadline
	// DefaultSlowAfter is when the watchdog first reports a unary RPC that is
	// still running.
	DefaultSlowAfter = observe.DefaultSlowAfter
	// DefaultSlowRepeat is how often the watchdog reports it again while it
	// keeps running.
	DefaultSlowRepeat = observe.DefaultSlowRepeat
)

The Deadline slot's defaults.

View Source
const (
	DefaultLivenessPath  = "/healthz"
	DefaultReadinessPath = "/readyz"
)

The default probe paths.

View Source
const AudienceUser = "user"

AudienceUser is the default audience: the product's own signed-in users. A proto service that declares no `(forge.v1.service).audience` serves it.

View Source
const DefaultDBConnectTimeout = 5 * time.Minute

DefaultDBConnectTimeout is the total budget for connecting to the database at startup when Config.DBConnectTimeout is unset. It is deliberately generous (five minutes): the wait costs nothing — the process is alive and reports not-ready — while giving up early reintroduces the crash loop this exists to remove. It stays inside a Deployment's default 600s progress deadline, so a dependency that never comes up still fails the rollout.

View Source
const DefaultDrainReserve = 5 * time.Second

DefaultDrainReserve is the part of the shutdown budget kept back after the long-lived streams are ended, when Streams.DrainReserve is zero.

View Source
const DefaultMaxBytes = 4 << 20

DefaultMaxBytes is the payload cap a Chain applies to each request and each response when ChainDeps leaves it zero.

View Source
const DefaultWriteStallTimeout = 60 * time.Second

DefaultWriteStallTimeout is how long one write to a response may stay blocked before the response is abandoned, when Config.WriteStallTimeout is left zero.

It bounds a STALL, not a response. A write is blocked only while the receiver is not draining what it was already sent, so a minute of that is a client that has stopped reading, not a slow one. A stream that keeps moving bytes is never affected, however long it runs.

View Source
const DrainedTrailer = "x-forge-drained"

DrainedTrailer is the response trailer set on a stream the drain ended. A client reads it after the stream ends to tell "the server is going away; reopen the stream" from "the stream is complete".

View Source
const EnvDevelopment = "development"

EnvDevelopment is the one Environment value that unlocks permissive edge defaults. It is compared exactly: "dev", "Development" and "" are all deployed environments, because a posture this permissive must never be reachable by a near-miss or by forgetting to set the variable at all.

View Source
const StreamTypeAttr = "forge.rpc.stream_type"

StreamTypeAttr is the attribute StreamDrainInterceptor stamps on the server span of a streaming RPC ("server", "client" or "bidi"). obsquery reads the same key (obsquery.StreamTypeAttr) to keep a stream's lifetime out of latency quantiles.

Variables

View Source
var ErrReceiveAborted = errors.New("serverkit: request body reads aborted by the handler")

ErrReceiveAborted is what a read of a request body returns once a handler ended it with AbortReceive. A body the drain ended reads as svcerr.ErrServerDraining instead, so StreamDrainInterceptor ends the stream by its client contract whichever way the handler reports it.

Functions

func AbortReceive added in v1.0.0

func AbortReceive(ctx context.Context) bool

AbortReceive ends the reading half of the request ctx belongs to: a Receive parked on its request body returns now, and every later one returns at once, with ErrReceiveAborted. The response is untouched — the handler still sends, and ends the RPC with whatever status it returns.

It is how a handler that reads its stream on a goroutine of its own stops that goroutine before it returns — Connect forbids using a stream once its handler has returned — and how a handler stops waiting on a client that is still connected but has nothing to send. Cancelling the handler's context does neither. Safe from any goroutine, and more than once; the first abort wins, so a stream the drain already ended keeps reading as drained.

It reports false, and does nothing, when the request was not served through Streams.Install (every listener NewHTTPServer builds installs it).

func AfterDrain added in v0.1.46

func AfterDrain(ctx context.Context, f func()) (stop func() bool)

AfterDrain arranges for f to run, in its own goroutine, when the drain of ctx's registry begins — the hook a websocket handler blocked in a read uses to close with "service restart" (1012) and set a short read deadline, so its read returns and its deferred cleanup runs while the context is still live. Like Draining, it claims the soft phase for the stream: the drain waits for the handler to return (until the deadline minus DrainReserve) instead of ending it at once.

stop unregisters f and reports whether it did so before f started; call it when the handler returns (defer stop()). f never runs after the handler's context ends for another reason — the client leaving, the handler finishing.

It is context.AfterFunc for the drain signal: scoped to one stream's context, not a server-wide hook. A server has no drain hook; what runs before http.Server.Shutdown waits is the listener's registry, and each stream reacts to it here.

func ApplyDBPoolTuning

func ApplyDBPoolTuning(db *sql.DB, t DBPoolTuning)

ApplyDBPoolTuning applies the four sql.DB pool knobs from t to db. A nil db or zero-value field is a no-op for the corresponding setting — Go's default is left in place.

The function is exported so projects that dial their own connection outside the AutoMigrate hook (e.g. for an out-of-band health check) can apply the same tuning without re-implementing the four-line loop.

func AutoMigrate

func AutoMigrate(cfg Config, logger *slog.Logger, migrate func(*sql.DB, *slog.Logger) error) error

AutoMigrate runs the project's migration function against a DEDICATED, short-lived connection, and is a no-op when cfg.AutoMigrate is false.

The whole ceremony is here — dial, apply the pool tuning, migrate, close — because getting it wrong is silent in one direction and fatal in the other. Callers invoke it BEFORE constructing components and mounting services: a service that starts serving against a schema its migrations have not reached yet answers requests with errors that look like bugs. That ordering is the caller's to keep (it is a line position in serve.go), but everything inside it is not.

The connection is closed on every path, including the migration-failure path, so a failed boot does not leak a pool into a crash loop. It is deliberately NOT the serving pool: migrations run once, at boot, and hold locks the serving pool must not be waiting behind.

migrate is the project's own generated pkg/app.AutoMigrate. Passing it in (rather than serverkit reaching for it) is what keeps serverkit free of any compile-time dependency on the project.

func AutoMigrateContext

func AutoMigrateContext(ctx context.Context, cfg Config, logger *slog.Logger, migrate func(*sql.DB, *slog.Logger) error) error

AutoMigrateContext is AutoMigrate with the boot context. The connect to the database RETRIES (capped exponential backoff with jitter, bounded by Config.DBConnectTimeout) instead of failing the process on the first refused dial: a database or pooler that comes up seconds after its clients is routine in a rollout, and a crash loop for it is a self-inflicted incident. Pass the ctx from StartBootProbes so /readyz names the database as pending while this waits. Only the migration itself is not retried — a failed or dirty migration is a human's problem, not a transient.

func AwaitMigrationsContext added in v1.0.0

func AwaitMigrationsContext(ctx context.Context, cfg Config, logger *slog.Logger, await func(context.Context, *sql.DB, *slog.Logger) error) error

AwaitMigrationsContext is AutoMigrateContext's counterpart for a process that leaves the schema to another (a root declared migrate=wait): it dials the database the same way — retrying within Config.DBConnectTimeout — and then blocks in await until the schema is current, with "schema" listed as pending in the boot /readyz body for the whole wait. Unlike AutoMigrateContext it does not consult Config.AutoMigrate: whether some process applies the migrations at boot or a deploy job does, a waiter serves only a current schema.

await is migratekit.AwaitMigrations over the binary's embedded migrations; passing it in keeps golang-migrate out of serverkit's link graph, as AutoMigrateContext's migrate does. The connection is closed on every path.

func Boot

func Boot(cfg Config) (*slog.Logger, *http.ServeMux)

Boot performs the two startup steps that must happen before a composition root can do anything else, and that every composition root does the same way: build the root logger from cfg and install it as slog's default, then create the mux services will mount onto.

The logger is built ONCE, here, and threaded through mount time and run time by the caller so mount-time and run-time logs agree — serverkit.Run will build its own only if Server.Logger is left nil, which is exactly the case this exists to avoid.

Boot does NOT normalize cfg. Normalization is the caller's step because the caller is what reads Config fields (the payload caps, the migration driver) before Run is entered; see Config.Normalize.

func CanAbortReceive added in v1.0.0

func CanAbortReceive(ctx context.Context) bool

CanAbortReceive reports whether AbortReceive can end the reads of the request ctx belongs to. A handler that cannot stop without it — one that reads on a goroutine it must join before returning — checks it up front and refuses the request otherwise, instead of leaking a reader.

func DrainDeadline added in v0.1.46

func DrainDeadline(ctx context.Context) (deadline time.Time, ok bool)

DrainDeadline reports when the drain of ctx's registry will end every stream still bound to it — the moment Drain moves from the soft phase to the hard one — so a stream that claimed the soft phase can tell its peer how long it has (a gateway telling a daemon when to have reconnected elsewhere). It is exact: the shutdown deadline minus the reserve Drain actually keeps, which is less than DrainReserve when the budget is under twice it.

ok is false until the drain has begun, for a drain with no deadline, for a drain begun without Drain (a bare BeginDrain or srv.Shutdown has no schedule), and for a context with no registry. A stream that did not claim the soft phase has no use for it: the drain ends it as it begins.

func DrainHTTP added in v0.1.46

func DrainHTTP(ctx context.Context, srv *http.Server, streams *Streams) error

DrainHTTP is a listener's drain: it stops srv accepting and drains its unary requests (http.Server.Shutdown) while streams drain soft-then-hard (Streams.Drain), both inside ctx's deadline. Run calls it after the readiness flip and the pre-stop pause; a process that runs its own listener calls it in place of srv.Shutdown.

It logs the listener phase — "drain: listener drained", with its elapsed and the streams' DrainReport — to streams.Logger and records it in streams.Meter. A nil streams is plain Shutdown, and logs nothing.

func Draining added in v0.1.46

func Draining(ctx context.Context) <-chan struct{}

Draining returns a channel that is closed when the drain of ctx's registry begins: this replica is going away, and a stream that has something to say goodbye with should say it now. Asking for it is how a bound stream CLAIMS the soft phase: the drain then leaves it running until it returns or the hard deadline ends it, instead of ending it at once. Ask before the drain — at the top of the handler — not after.

For a context with no registry the channel never closes.

func Ended added in v0.1.46

func Ended(ctx context.Context) bool

Ended reports whether ctx was ended by the drain — as opposed to the client going away or the handler finishing on its own. A handler checks it to choose how to say goodbye: a status its client reconnects on, rather than one that reads as "the stream is over". It holds for every context derived from a bound one.

func LogTelemetryFlushError added in v1.0.0

func LogTelemetryFlushError(ctx context.Context, logger *slog.Logger, msg string, err error)

LogTelemetryFlushError logs a failure of the final telemetry flush at shutdown — the OTel provider shutdown (Run) or a supervisor's Flush (lifecyclekit.Supervise).

A flush that ran out of the shutdown budget is a WARN, not an ERROR: the process is exiting, its last batch is lost, and nobody can act on this one line — a collector that stays unreachable is caught by the telemetry-arrival checks, which see every process go quiet, not one. As an ERROR it opened an issue on every rollout (control-plane prod, 2026-10-23..25: admin-server, reliant-api-server, daemon-gateway and the workers, each on pod termination). Any other flush failure — an exporter that refused the data — stays an ERROR.

func LongLived added in v0.1.46

func LongLived(h http.Handler) http.Handler

LongLived marks h's requests as long-lived streams — Server-Sent Events, a websocket, a long poll, an MCP GET event stream — by binding each one to the registry of the listener serving it. Never wrap a unary route: a rollout would cut it short instead of draining it.

A connection the handler takes over with Hijack — a websocket upgrade — is registered with the drain too. http.Server.Shutdown neither waits for nor closes a hijacked connection, so without this the process exit would drop it mid-session; the drain instead waits for the handler (which should close it with a status its client reconnects on, from AfterDrain) and closes the connection itself only if the handler is still running at the end of the reserve, so the handler unwinds and its deferred cleanup runs.

A request no registry was installed for (a server not built by NewHTTPServer or Streams.Install) is served unbound, and the first one is logged, because nothing will end it at shutdown.

func NewHTTPServer added in v0.1.46

func NewHTTPServer(cfg Config, handler http.Handler, streams *Streams) *http.Server

NewHTTPServer builds the serving http.Server Run uses around handler: the protocols (HTTP/1.1, HTTP/2 over TLS, and h2c), the write-stall guard, the connection timeouts and HTTP/2 stream cap from cfg — and, when streams is non-nil, the listener's drain registry: installed into every request's context (Streams.Install), so LongLived routes and StreamDrainInterceptor bind to it, and its soft phase registered to begin at the start of Shutdown, so even a bare srv.Shutdown ends the streams that did not claim it. That registration is the ONE thing that runs before Shutdown waits; there is no separate drain hook. It is exported for a process that owns its own listener and lifecycle; such a process calls DrainHTTP(ctx, srv, streams) after a Drainer's Begin.

There is deliberately NO WriteTimeout. It is a deadline on the whole response, so it cuts every long-lived response at the same mark — SSE, Connect and gRPC server streams, reverse-proxied LLM completions, and websockets. Slow-read protection instead belongs to WriteStallGuard, which bounds a write that cannot complete without capping a response that takes a long time.

h2c is net/http's own (Protocols.SetUnencryptedHTTP2), not x/net's h2c.NewHandler. That handler HIJACKS each h2c connection, and a hijacked connection is invisible to http.Server.Shutdown: Run's graceful drain returned at once while every h2c request was still in flight, so a rolling deploy behind an h2c load balancer — every Connect and gRPC call — cut them all. A native h2c connection is the server's own, so Shutdown sends it GOAWAY and waits for its streams, and each stream is a request the drain registry sees. (Native h2c takes prior knowledge, which is what Connect, gRPC and load balancers send; an HTTP/1.1 `Upgrade: h2c` request is served as HTTP/1.1.)

func NewLogger

func NewLogger(cfg Config) *slog.Logger

NewLogger builds the slog.Logger serverkit would use from Config: LOG_FORMAT picks between structured JSON (for log aggregators) and human-friendly text (for local dev tails); anything other than "text" emits JSON. Exported so the cmd layer — which now composes the server and bootstraps BEFORE calling Run — can build the SAME logger and pass it as Server.Logger, keeping mount-time and run-time logs consistent.

Every record goes through observe.NewErrorClassHandler, so a hand-written logger.Error carrying a user error (svcerr.Classify) is written at INFO rather than paging. A project that wraps this handler with something that routes on level — a Sentry forwarder — wraps the result in observe.NewErrorClassHandler again, OUTSIDE its forwarder; the inner one then sees an already-classified record and leaves it alone.

func OpenDB

func OpenDB(ctx context.Context, driver, dsn string, pool DBPoolTuning, p ConnectPolicy) (*sql.DB, error)

OpenDB opens a *sql.DB, applies the pool tuning, and verifies connectivity with RetryConnect. With pool.SpareConns set, that many connections are kept dialled ahead of need for the life of the pool (spare_conns.go). The pool is closed on failure, so a database that never comes up does not leak a half-open pool into the exit path.

The "pgx" driver (the default) is opened so that the pool rides through a pooler's rollout: a statement a draining PgBouncer refused is run again on another connection, and closing a connection the pooler already dropped is not reported as an error. See pooler_drain.go.

func PgxConn added in v1.0.0

func PgxConn(driverConn any) (*pgx.Conn, bool)

PgxConn returns the *pgx.Conn under a driver connection handed to sql.Conn.Raw's callback, through every wrapper OpenDB puts around it (the tracing driver, the pooler-drain connection).

func ProbePathList added in v1.0.0

func ProbePathList(s string) []string

ProbePathList parses a comma-separated path list — the LIVENESS_PATHS / READINESS_PATHS config value — trimming each entry and dropping empty ones. An empty string is an empty list, which is the default path.

func RESTHandler

func RESTHandler(mux *http.ServeMux, connectPaths []string, logger *slog.Logger) http.Handler

RESTHandler serves the REST URLs that the MOUNTED services' google.api.http annotations declare, in front of mux. A request mux has a route for — every Connect procedure, and anything else registered on it — goes to mux unchanged; any other request is matched against the REST routes, and one that matches none gets vanguard's 404.

It returns nil when nothing was mounted, and nil when the REST routes cannot be built — in both cases the caller keeps serving the bare mux rather than serving nothing. That is the deliberate choice: losing REST URLs is a degradation, losing Connect is an outage, and REST routes that failed to build are not evidence the Connect handlers are broken.

connectPaths are the fully-qualified Connect service names a mount returned — the same values the completeness gate records. A path form ("/pkg.v1.Service/") is accepted too.

func RequireMounted

func RequireMounted(srv *Server, src DescriptorSource) error

RequireMounted is the package-level form of (*Server).RequireMounted, for callers who prefer serverkit.RequireMounted(srv, src) reading order. It is a thin forward — identical behaviour.

func RetryConnect

func RetryConnect(ctx context.Context, p ConnectPolicy, attempt func(ctx context.Context) error) error

RetryConnect calls attempt until it succeeds, the policy's total Timeout elapses (a *ConnectTimeoutError), or ctx is cancelled (ctx.Err() wrapped). attempt receives a context already bounded by AttemptTimeout.

While ctx carries a *BootProbes (StartBootProbes), the dependency is listed as pending in the boot /readyz body for the duration of the wait.

func Run

func Run(ctx context.Context, cfg Config, srv Server) error

Run starts the server and blocks until SIGINT/SIGTERM or a fatal error. It takes an ALREADY-COMPOSED Server (handler with services mounted, selected workers/operators, an OnShutdown closure) and owns only the uniform lifecycle: logger init, health probes, the HTTP edge (CORS/security/request-id driven by Config + Server's factory fields), listener bind, worker supervision, operator-manager gating, graceful shutdown, and pprof.

Service SELECTION (the old args/names filter) and composition (mux build, service mount, interceptor chain, OTel setup, auto-migrate) all happen ABOVE serverkit in the generated cmd-server shim. serverkit no longer receives names and knows nothing about which services run.

Run is the only entry point projects call. Every other type and helper exists to shape the Server surface or document the lifecycle.

func ServiceAudience added in v0.1.46

func ServiceAudience(sd protoreflect.ServiceDescriptor) string

ServiceAudience is the audience a proto service declares with `option (forge.v1.service) = {audience: "..."}` — AudienceUser when it declares none.

func SetupTelemetry added in v0.1.46

func SetupTelemetry(ctx context.Context, cfg Config) (func(context.Context) error, http.Handler, error)

SetupTelemetry installs the process's OpenTelemetry providers from cfg (OTLPEndpoint, ServiceName and the standard OTEL_* environment) and returns their flush — run it LAST on the way down, so the shutdown's own spans and metrics are exported — and the Prometheus /metrics handler, for Probes.Metrics. Run calls it itself; a process that supervises its own components (lifecyclekit.Supervise) calls it before building them, so every instrumented client is created against the installed providers.

func StreamDrainInterceptor added in v0.1.46

func StreamDrainInterceptor() connect.Interceptor

StreamDrainInterceptor binds every server-streaming and bidi Connect handler to the drain registry of the listener serving it (StreamsFrom), and ends a stream the drain ended with the status the client contract above defines. It takes no arguments: the registry, and its RedialOnDrain list, come from the request context, which NewHTTPServer populates. Put it in the interceptor chain inner to logging and metrics, so they record the status the client received.

func WriteStallGuard

func WriteStallGuard(h http.Handler, stall time.Duration) http.Handler

WriteStallGuard bounds how long any single write to a response may block, in place of http.Server.WriteTimeout. It is what Run installs; it is exported for processes that build their own http.Server.

Why not WriteTimeout

http.Server.WriteTimeout is a deadline on the WHOLE response, armed when the request headers are read. It cannot tell a client that stopped reading from a response that is legitimately long-lived, so it cuts both: every Server-Sent Events stream, Connect or gRPC server stream, reverse-proxied LLM completion and websocket (a hijacked conn keeps the deadline) dies at the same wall-clock mark however healthy it is. serverkit hard-coded 60s, and in production that ended every LLM stream longer than a minute with "unexpected EOF" — and, because the cut landed before the provider's final usage event, left each one unbilled.

The protection WriteTimeout was there for is against a slow-read client: a peer that stops draining the socket, so a write blocks forever and pins the handler goroutine and its buffers. That threat is a write that does not COMPLETE, so that is what this bounds: before each Write or Flush the connection's write deadline is armed to now+stall, and afterwards it is put back. Time spent between writes (a handler waiting on an upstream, an idle stream between events) is never counted. The final flush net/http performs after the handler returns is covered by arming the deadline as the handler exits.

The same policy applies after Hijack. The returned net.Conn is a deadline wrapper: each Write gets the stall budget, while an idle websocket remains open indefinitely. Libraries that write through the returned bufio.Writer get the same protection.

Composing with a handler's own deadline

A handler that wants a whole-response cap sets it the standard way, with http.NewResponseController(w).SetWriteDeadline. The guard intercepts that call, honours the earlier of the two deadlines during each write, and restores the handler's between writes, so the two compose rather than the guard silently erasing the handler's cap.

stall <= 0 returns h unchanged.

Types

type Authenticator added in v0.1.46

type Authenticator interface {
	connect.Interceptor
	// Audience names the trust domain this authenticator admits: AudienceUser
	// ("user"), or a name the project chooses ("daemon", "operator").
	Audience() string
	// Caller reads, from a request context, the caller this authenticator
	// admitted, as forge claims. nil means it does not say — its auth keeps
	// the caller somewhere forge cannot read — and NewChain then refuses to
	// default Audit or Activity, because both would record every call as
	// anonymous.
	Caller() CallerLookup
}

Authenticator is the authentication slot of a Chain: the interceptor that proves who is calling, the audience — the kind of caller — it admits, and how to read the caller it admitted. Audience is what a Listener compares with each mounted service's declared `(forge.v1.service).audience`; Caller is what the chain's caller-recording slots (Audit, Activity) read.

func AuthFor added in v0.1.46

func AuthFor(audience string, interceptor connect.Interceptor) Authenticator

AuthFor pairs an authentication interceptor with the audience it admits — the usual way to fill ChainDeps.Auth:

serverkit.AuthFor(serverkit.AudienceUser, authInterceptor)

Its Caller is the interceptor's own: forge/pkg/authn's names the claims it stashes (authn.ClaimsFromContext). An interceptor that does not say how to read its caller — a project's own, or authn's with a project-owned Policy.ContextWithClaims — leaves Caller nil; build that one with AuthWithCaller instead. A nil interceptor or an empty audience is rejected by NewChain.

func AuthWithCaller added in v1.0.0

func AuthWithCaller(audience string, interceptor connect.Interceptor, caller CallerLookup) Authenticator

AuthWithCaller is AuthFor for an interceptor that keeps the caller its own way: caller reads it back from a request context as forge claims, so the chain's default Audit and Activity record the real caller. A nil caller is rejected by NewChain.

serverkit.AuthWithCaller(serverkit.AudienceUser, appAuth, appauth.Claims)

type BootProbes

type BootProbes struct {
	// contains filtered or unexported fields
}

BootProbes is the early probe listener. The zero value is not usable; use StartBootProbes. A nil *BootProbes is valid and inert, so callers need no guard.

func BootProbesFrom

func BootProbesFrom(ctx context.Context) *BootProbes

BootProbesFrom returns the probes StartBootProbes put on ctx, or nil.

func StartBootProbes

func StartBootProbes(ctx context.Context, cfg Config, logger *slog.Logger) (context.Context, *BootProbes, error)

StartBootProbes binds cfg.Addr and serves the boot probes. It returns ctx carrying the probes: pass THAT ctx to OpenDB / RetryConnect / AutoMigrate (which then report the dependency they wait on) and to Run (which takes the listener over). Close it on error paths; it is idempotent.

func (*BootProbes) Close

func (b *BootProbes) Close()

Close stops the boot listener. Idempotent; safe on nil.

func (*BootProbes) Constructing added in v1.0.0

func (b *BootProbes) Constructing(node string) (done func(err error))

Constructing records that a composition-graph node's constructor has started: /readyz lists node as building (ok false, detail "building") until the returned done is called with the constructor's error, and then as built (ok true) when that error is nil. A failed node is dropped — the build that called it fails boot with the cause. It satisfies graphkit's Progress, so a generated graph reports its construction here (graphkit.Builder.ReportTo). Safe on nil, which records nothing.

type CallerLookup added in v1.0.0

type CallerLookup = func(ctx context.Context) (*authn.Claims, bool)

CallerLookup reads the authenticated caller's forge claims from a request context: authn.ClaimsFromContext for forge's own auth, or a project's reader over its own auth context. It is the same shape as middleware.ClaimsLookup and activity.ClaimsLookup.

type Chain added in v0.1.46

type Chain struct {
	// contains filtered or unexported fields
}

Chain is a built interceptor chain: the ordered interceptors and payload caps every Connect handler mounted with it receives, and the audience its Authenticator admits. Build it with NewChain.

func NewChain added in v0.1.46

func NewChain(deps ChainDeps) (*Chain, error)

NewChain builds the chain, outermost first: trace → recovery → request-id → logging → tracing → metrics → deadline → auth → audit → rate-limit → extras → activity → stream-drain → protovalidate → svcerr (the handler's errors become their Connect codes). Every slot but Auth is defaulted; see ChainDeps for each default and how to drop it. It fails when Auth is missing or names no audience, when an Omit is given for the wrong slot, and when a default cannot be built — a chain missing a layer it was meant to have is a boot failure, not a log line.

func (*Chain) Audience added in v0.1.46

func (c *Chain) Audience() string

Audience is the audience the chain's Authenticator admits.

func (*Chain) HandlerOptions added in v0.1.46

func (c *Chain) HandlerOptions() []connect.HandlerOption

HandlerOptions returns the options every Connect handler mounted with this chain is built with: the interceptors and the payload caps.

func (*Chain) Interceptors added in v0.1.46

func (c *Chain) Interceptors() []connect.Interceptor

Interceptors returns the chain's interceptors, outermost first.

type ChainDeps added in v0.1.46

type ChainDeps struct {
	// Auth authenticates every request and names the audience the chain
	// admits. Required: a nil Auth is a NewChain error, so a server with no
	// authenticator fails at boot instead of serving.
	Auth Authenticator

	// Logger feeds recovery, logging and the default audit interceptor. nil
	// is slog.Default().
	Logger *slog.Logger

	// Trace opens the server span; it runs OUTERMOST, so a request auth
	// rejects still has a span and every log line carries its trace_id. nil
	// is otelconnect with WithTrustRemote — an inbound traceparent (the
	// browser's) becomes the span's parent. A chain facing callers that
	// must not choose its trace ids passes observe.TrustRemoteBy, which
	// adopts as much of each caller's context as it authenticates to.
	// NoTrace drops it.
	Trace connect.Interceptor

	// Deadline bounds every RPC's time and reports one still running past
	// its watchdog threshold. It runs after observability, so a
	// DeadlineExceeded is logged and counted with its full duration, and
	// BEFORE Auth, so authenticating — a token store query, an
	// introspection call — runs inside the RPC's budget. nil is
	// serverkit.Deadline{}: unary RPCs bounded by DefaultUnaryDeadline,
	// streams unbounded, a unary RPC still running after DefaultSlowAfter
	// logged at WARN through Logger. Configure it in place
	// (serverkit.Deadline{Procedures: middleware.ProcedureDeadlines}); any
	// other interceptor here replaces it and runs in the same position.
	// NoDeadline drops it.
	Deadline connect.Interceptor

	// Audit records who called what, after Auth. nil is
	// middleware.AuditInterceptor over Auth's Caller — and a NewChain error
	// when Auth's Caller is nil, since it would audit every call as
	// anonymous. NoAudit drops it.
	Audit connect.Interceptor

	// RateLimit runs after Audit. It has no default — a rate limit is a
	// budget only configuration can name — so nil is none.
	// middleware.RateLimitInterceptor returns nil for a zero rate, so its
	// result can be passed through unconditionally.
	RateLimit connect.Interceptor

	// LogOptions tune how the logging interceptor records successful RPCs
	// (observe.WithSuccessSampling). nil logs every one.
	LogOptions []observe.LogOption

	// Extras are the project's own interceptors, run after RateLimit and
	// before Activity, the stream drain and protovalidate.
	Extras []connect.Interceptor

	// Activity writes the typed activity event each rpc declares in
	// `(forge.v1.method).activity` (forge/pkg/activity), after the handler
	// succeeds. It runs after Auth, so it knows the caller, and next to the
	// handler, so it judges the handler's own outcome. nil is
	// activity.Interceptor with no store, its Principal read from Auth's
	// Caller (activity.ClaimsPrincipal): declared events are logged, not
	// persisted. nil is a NewChain error when Auth's Caller is nil, since
	// every event would name an anonymous actor; pass
	// activity.Interceptor(activity.Deps{Principal: …}) with the project's
	// own Principal then. Pass activity.Deps{Writer: activity.NewStore(db)}
	// to keep events. NoActivity drops it.
	Activity connect.Interceptor

	// StreamDrain binds every server-streaming and bidi RPC to the drain of
	// the listener serving it, so a rollout ends it with a status its client
	// reconnects on (stream_drain.go). nil is StreamDrainInterceptor().
	// NoStreamDrain drops it.
	StreamDrain connect.Interceptor

	// Validate rejects a request whose buf.validate rules fail, with
	// InvalidArgument, right before the handler: it is the innermost layer.
	// nil is protovalidate (pkg/validate). NoValidate drops it.
	Validate connect.Interceptor

	// ReadMaxBytes and SendMaxBytes cap one request's and one response's
	// payload. connect-go reads and decodes a whole body before any
	// interceptor runs, so an uncapped handler turns one anonymous request
	// into an OOMKill. Zero is DefaultMaxBytes (4 MiB); negative is no cap.
	ReadMaxBytes int
	SendMaxBytes int
}

ChainDeps configures a Chain. Auth is required; every other slot left nil is forge's default, and a defaulted slot is dropped only by its Omit constant.

type Config

type Config struct {
	// Addr is the public listener address (e.g. ":8080"). Required.
	Addr string

	// PprofAddr is the side-listener for net/http/pprof, and forge's
	// scaffold sets it ON by default (127.0.0.1:6060) rather than leaving
	// it empty. Never mount pprof on the public Addr — its endpoints can
	// leak memory and stall the process — which is why this is a separate
	// address rather than a path on the main mux.
	//
	// The default is LOOPBACK, and that is what makes always-on defensible:
	// the listener exists in every environment and is routable from none of
	// them (the scaffolded manifests give it no Service, route, or published
	// port). Reach it with:
	//
	//	go tool pprof http://localhost:6060/debug/pprof/heap
	//	kubectl port-forward <pod> 6060:6060   # then the line above
	//
	// Set a wildcard address (0.0.0.0:6060) where a profile genuinely has to
	// cross a container boundary. Empty disables the pprof server entirely.
	//
	// A failed bind is NEVER fatal: Run logs it and serves on without pprof,
	// regardless of FailurePolicy. A debug surface that can kill the process
	// is worse than no debug surface.
	PprofAddr string

	// TLSCertPath and TLSKeyPath enable TLS when both are non-empty.
	// Validation of "both or neither" must happen in the project's
	// config layer before Run is called.
	TLSCertPath string
	TLSKeyPath  string

	// LogLevel is the slog level applied to the root logger. Zero
	// value is slog.LevelInfo.
	LogLevel slog.Level

	// LogFormat selects the slog handler. "text" emits text;
	// anything else (including "" and "json") emits JSON.
	LogFormat string

	// Environment is the deployment environment string. When equal to
	// EnvDevelopment Run emits a loud warning about permissive defaults,
	// suppresses HSTS, and enforces CORS even with an empty CORSOrigins
	// (see CORSEnabled). Every other value — including "" — is treated as
	// a deployed environment: closed by default.
	Environment string

	// OTLPEndpoint is the base OTLP collector endpoint (e.g.
	// "http://localhost:4318"); the wire protocol (http/protobuf by default,
	// grpc) and headers come from OTEL_EXPORTER_OTLP_PROTOCOL / _HEADERS.
	// Empty defers to OTEL_EXPORTER_OTLP_ENDPOINT read by the SDK. serverkit OWNS OpenTelemetry setup: Run
	// calls observe.Setup internally with this endpoint, installs the
	// global trace/metric providers, mounts the Prometheus /metrics handler
	// on its own edge, and flushes the providers during graceful shutdown.
	// Empty means no OTLP exporter is wired — the always-on Prometheus
	// /metrics path still works. The caller no longer builds a cmd/otel.go
	// shim; it projects this (and ServiceName) from its typed config.
	OTLPEndpoint string

	// ServiceName is the logical service name reported on OTel traces and
	// metrics (semconv service.name). It is APP IDENTITY (the project name),
	// not a per-env knob — the caller passes a generated constant. Empty
	// falls back to observe.Setup's "unknown".
	ServiceName string

	// ServiceVersion is reported as semconv service.version (the binary's
	// build version). The "dev" sentinel / empty are treated as "no
	// version" by observe.Setup.
	ServiceVersion string

	// AutoMigrate signals the caller-owned migration step should run.
	// serverkit no longer runs migration itself — the cmd layer reads
	// this flag (plus DatabaseURL/DBDriver/DBPoolTuning below) and runs
	// the migration before calling Run. The fields remain on Config so
	// the projection from the project's typed config stays in one place.
	AutoMigrate bool

	// DatabaseURL is the DSN the caller passes to sql.Open for its
	// migration connection. Required (caller-side) when AutoMigrate is
	// true.
	DatabaseURL string

	// DBDriver is the sql.Open driver name (e.g. "pgx"). Empty
	// defaults to "pgx".
	DBDriver string

	// DBPoolTuning is applied (via ApplyDBPoolTuning) by the caller to
	// its migration connection before the migration runs.
	DBPoolTuning DBPoolTuning

	// DBConnectTimeout is the TOTAL budget for connecting to the database at
	// startup (AutoMigrate and OpenDB). The connect retries with capped
	// exponential backoff and jitter inside it, the process stays up and
	// reports not-ready meanwhile, and only a database still unreachable at
	// the deadline fails the process. Zero falls back to
	// DefaultDBConnectTimeout (5m). The rendered startup probe window must
	// cover it — see v1alpha1.StartupWindowSeconds.
	DBConnectTimeout time.Duration

	// CORSOrigins is the allow-list handed to the CORSMiddleware factory.
	// Each entry must be a full origin (scheme + host + optional port).
	//
	// It does NOT decide whether CORS runs — CORSEnabled does. In
	// development the factory is invoked with an empty list (the project's
	// factory then selects its origin-reflecting dev policy, which ignores
	// the list); in a deployed environment an empty list means no CORS
	// layer at all.
	CORSOrigins []string

	// CORSAllowCredentials toggles Access-Control-Allow-Credentials.
	// The wildcard-origin + credentials combination must be rejected
	// by the project's config validation before Run is called.
	CORSAllowCredentials bool

	// SecurityHeaders enables the OWASP security-header middleware.
	// HSTS is only emitted when Environment != "development".
	SecurityHeaders bool

	// PreStopDelay is the readiness-flip drain pause. Zero falls back
	// to 5s; negative is no pause. Operators tune this to match their
	// load-balancer probe interval — too short causes brief 502s on
	// rollout.
	//
	// PreStopDelay and ShutdownTimeout follow the rule every Config
	// duration does — zero is the default, negative is none — and "none"
	// for both is a phase that takes no time: no pause, and a shutdown
	// window that is over as it opens (in-flight requests and streams are
	// cut, teardown runs on an expired context). It is never "unbounded":
	// forge derives the pod's grace period from the two, and the kubelet
	// would end an unbounded shutdown at it anyway.
	PreStopDelay time.Duration

	// ShutdownTimeout bounds the post-readiness-flip shutdown window
	// (worker Stop, Server.OnShutdown, http.Server.Shutdown all
	// share this budget). Zero falls back to 30s; negative is no window
	// (see PreStopDelay).
	ShutdownTimeout time.Duration

	// DrainReserve is the part of ShutdownTimeout kept back after the
	// long-lived streams are ended (Streams.DrainReserve): a stream that
	// claimed the soft phase (serverkit.Draining) may run until the deadline
	// minus this, and the rest of the teardown — workers, OnShutdown, the
	// telemetry flush — runs in what remains. Zero falls back to
	// DefaultDrainReserve (5s); never more than half the budget.
	DrainReserve time.Duration

	// RedialOnDrain names the Connect procedures ("/pkg.v1.Svc/Method", the
	// generated <Svc><Method>Procedure constant) or whole services
	// ("pkg.v1.Svc", the generated <Svc>Name constant) whose streams the
	// drain ends with Unavailable instead of OK — for a peer that redials on
	// Unavailable, such as a daemon holding a bidi stream. Empty: every
	// drained stream ends OK, with the x-forge-drained trailer. See
	// StreamDrainInterceptor.
	RedialOnDrain []string

	// ReadMaxBytes caps the size of a single Connect request payload.
	// Zero falls back to 4 MiB — but ONLY after Normalize has run. A
	// zero reaching connect.WithReadMaxBytes means UNLIMITED, so any
	// caller that reads this field before Run must Normalize first.
	ReadMaxBytes int

	// SendMaxBytes caps the size of a single Connect response payload.
	// Zero falls back to 4 MiB after Normalize. See ReadMaxBytes for
	// why an un-normalized read is dangerous.
	SendMaxBytes int

	// ReadinessTimeout bounds ALL of a /readyz request's dependency
	// checks together (not each one), so the endpoint's worst case stays
	// inside a kubelet probe's timeoutSeconds no matter how many checks
	// are registered. Zero falls back to 2s.
	ReadinessTimeout time.Duration

	// Probes are the paths the process answers its liveness and readiness
	// probes on — the boot probes, serverkit.Run's listener and a Probes
	// surface alike. Empty is DefaultLivenessPath / DefaultReadinessPath.
	// The scaffold projects them from the liveness_paths / readiness_paths
	// config fields, which the KCL lowering reads too, so the probe a pod
	// is given is the one its process serves.
	Probes ProbePaths

	// ReadHeaderTimeout bounds reading a request's headers (slowloris).
	// Zero falls back to 10s; negative disables it.
	ReadHeaderTimeout time.Duration

	// ReadTimeout bounds reading a whole request, body included — over
	// HTTP/2 it is a deadline on each stream's request body. So it caps how
	// long a client-streaming or bidirectional Connect/gRPC call may keep
	// sending: a server with long-lived client streams sets it negative.
	// Zero falls back to 30s; negative disables it.
	ReadTimeout time.Duration

	// IdleTimeout is how long a keep-alive connection may sit idle (HTTP/1.1
	// and HTTP/2). Zero falls back to 120s; negative disables it, which
	// leaves idle connections open until the client closes them.
	IdleTimeout time.Duration

	// HTTP2MaxConcurrentStreams caps the concurrent streams one HTTP/2
	// connection (TLS or h2c) may open. A client multiplexing many
	// long-lived streams over one connection needs it raised. Zero leaves
	// net/http's default (250 at the time of writing).
	HTTP2MaxConcurrentStreams int

	// WriteStallTimeout bounds how long ONE write to a response may stay
	// blocked on a client that is not reading, after which the response is
	// abandoned (see WriteStallGuard). It is not a cap on how long a response
	// may take: a stream that keeps moving bytes runs as long as it likes.
	// Zero falls back to DefaultWriteStallTimeout; negative disables the
	// guard. A handler that wants a whole-response cap sets one itself with
	// http.NewResponseController(w).SetWriteDeadline — it composes with this.
	WriteStallTimeout time.Duration

	// OperatorHealthProbeAddr is forwarded to Server.RunOperators
	// for projects (like cp-forge) that bind a /healthz + /readyz
	// listener inside the controller manager. Empty string leaves the
	// project's RunOperators to fall back to its own default.
	OperatorHealthProbeAddr string

	// FailurePolicy governs worker Start/RunContext errors and
	// RunOperators errors. Zero value is FailProcess (a component
	// failure terminates the process loudly); set Ignore to restore
	// log-and-continue. See the FailurePolicy doc.
	FailurePolicy FailurePolicy
}

Config bundles every knob Run reads from the project's *config.Config. The generated cmd/server.go shim projects its typed config onto this struct so serverkit has no compile-time dependency on the project's config package.

Fields with zero values fall back to sensible defaults documented per field; Run never returns "missing required field" for any of these.

func (Config) CORSEnabled

func (c Config) CORSEnabled() bool

CORSEnabled reports whether Run wraps the caller's handler in the CORS layer — and therefore whether Server.CORSMiddleware is required.

Two ways to be on, and the second one is the whole point:

  • A non-empty CORSOrigins. An operator naming origins wants them enforced, in every environment.
  • Development. A freshly scaffolded project has no CORS_ORIGINS value to name — nothing defaults it, and its own frontend's dev port is not knowable to the server — so gating solely on a non-empty list made the generated factory's development branch unreachable: the browser frontend could not call its own backend until someone hand-curated an allow-list. Development enables the layer with an empty list and lets the caller's factory choose the dev policy.

The dev arm is deliberately the ONLY relaxation: a staging or production Config with no origins gets no CORS layer, exactly as before.

func (Config) ConnectPolicy

func (c Config) ConnectPolicy(logger *slog.Logger) ConnectPolicy

ConnectPolicy returns the policy a Config describes: its DBConnectTimeout and the given logger. A project passes this to OpenDB.

func (Config) IsDevelopment

func (c Config) IsDevelopment() bool

IsDevelopment reports whether this Config runs the development posture.

func (Config) Listener added in v0.1.46

func (c Config) Listener() ListenerConfig

Listener projects the listener half of a process Config — the block serverkit.Run's one listener is built from.

func (*Config) Normalize

func (c *Config) Normalize()

Normalize projects unset Config fields onto their fallback values. It is idempotent, so calling it more than once is free.

Run calls it at entry, but Run is NOT the only reader: the generated composition root builds the Connect handler options (payload caps) and opens the migration DB connection from the SAME Config *before* Run is entered. A field left zero at that point is not "the default" — it is the zero value, and for the payload caps connect-go documents zero as UNLIMITED, which turns one anonymous request into an OOMKill.

So: whoever reads a Config field outside Run normalizes first. The generated projectServerkitConfig does exactly that, as the last step before it hands the Config back, which is why no reader downstream of it can observe a zero.

type ConnectPolicy

type ConnectPolicy struct {
	// Name identifies the dependency in logs and in the boot /readyz body.
	// Default "database".
	Name string
	// Timeout is the TOTAL budget across all attempts. Default
	// DefaultDBConnectTimeout.
	Timeout time.Duration
	// AttemptTimeout bounds ONE attempt, so a blackholed host (a dropped
	// SYN never errors) costs one attempt's budget rather than the whole
	// total. Default 5s.
	AttemptTimeout time.Duration
	// InitialBackoff is the delay after the first failure; it doubles up to
	// MaxBackoff, with jitter. Defaults 500ms / 10s.
	InitialBackoff time.Duration
	MaxBackoff     time.Duration
	// LogEvery is the width of a log bucket: one WARN per bucket (the first
	// failure always logs), summarising the attempts inside it. Default 15s.
	LogEvery time.Duration
	// Logger receives the WARN/INFO lines. Nil uses slog.Default().
	Logger *slog.Logger
}

ConnectPolicy tunes RetryConnect / OpenDB. Every zero field takes its default, so ConnectPolicy{} is a complete, production-shaped policy.

type ConnectTimeoutError

type ConnectTimeoutError struct {
	Name     string
	Elapsed  time.Duration
	Attempts int
	Class    string
	Last     error
}

ConnectTimeoutError is returned when the dependency was still unreachable at the deadline. It wraps the last attempt's error.

func (*ConnectTimeoutError) Error

func (e *ConnectTimeoutError) Error() string

func (*ConnectTimeoutError) Unwrap

func (e *ConnectTimeoutError) Unwrap() error

type ContextWorker

type ContextWorker interface {
	Worker

	// RunContext runs the worker's main loop and returns when ctx is
	// done. See the interface doc for the full shutdown contract.
	RunContext(ctx context.Context) error
}

ContextWorker is an OPTIONAL extension of Worker for context-aware run loops. When a worker in Server.Workers also implements ContextWorker, the supervisor calls RunContext instead of Start, passing a per-worker context derived from the run lifecycle. Workers that don't implement it keep the legacy Start path unchanged — the supervisor type-asserts at fan-out time, so adopting the interface is purely additive.

The shutdown contract:

  • ctx is cancelled the moment graceful shutdown begins (SIGINT/ SIGTERM received, or a fatal serve error). RunContext must observe ctx.Done() inside its cycle loop — and thread ctx into per-tick work (DB queries, HTTP calls, adapters) — so an in-flight cycle is interrupted rather than running to completion.
  • RunContext must return promptly after cancellation. The supervisor waits for every worker goroutine to exit before continuing the shutdown sequence, so a worker that ignores ctx stalls shutdown.
  • Returning nil or ctx.Err() after cancellation is a clean exit — the supervisor does not log context.Canceled as a worker error. Any other error is dispatched on Config.FailurePolicy: FailProcess (the default) terminates the process with the worker's error; Ignore logs and continues. Workers are never restarted in-process — restart is the platform supervisor's job.
  • Stop is still called afterwards (every WorkerList entry is a Worker), bounded by Config.ShutdownTimeout. For ContextWorker implementations Stop is typically a final-drain no-op since cancellation already unwound the run loop.

Cron-scheduled and continuous workers both fit: a continuous worker selects on ctx.Done() in its cycle loop; a cron worker derives each tick's context from ctx so scheduled jobs observe shutdown mid-run.

type DBPoolTuning

type DBPoolTuning struct {
	// MaxOpenConns caps total open connections. 0 = unlimited.
	MaxOpenConns int

	// MaxIdleConns caps idle connections. 0 = default (2).
	MaxIdleConns int

	// ConnMaxIdleTime is how long an idle connection survives before
	// being closed. Zero disables the limit.
	ConnMaxIdleTime time.Duration

	// ConnMaxLifetime is the absolute lifetime of any connection.
	// Zero disables the limit.
	ConnMaxLifetime time.Duration

	// SpareConns keeps this many connections dialled ahead of need, so
	// when the pool needs a new connection it takes a spare instead of
	// making the request wait for a dial (see spare_conns.go). Capped at
	// MaxOpenConns, and while that is set, spares are dialled only into the
	// room the pool's open connections leave under it. 0 = off,
	// database/sql's own behavior: dial on demand.
	SpareConns int
}

DBPoolTuning groups the sql.DB pool knobs: the four ApplyDBPoolTuning sets on a pool, and SpareConns, which OpenDB keeps dialled. Values come from the project config so operators can tune them per environment without recompiling. A zero/empty field leaves the corresponding setting at Go's default.

type Deadline added in v1.0.0

type Deadline = observe.Deadline

Deadline is the Deadline slot of a Chain: the time budget of every RPC and the watchdog that reports a slow one. It is observe.Deadline.

type DescriptorSource

type DescriptorSource interface {
	RangeFiles(func(protoreflect.FileDescriptor) bool)
}

DescriptorSource is the minimal proto-descriptor reader RequireMounted walks. *protoregistry.Files satisfies it directly, so callers pass protoregistry.GlobalFiles (the process-wide linked set) in production and a scoped *protoregistry.Files in hermetic tests. Defining the seam as an interface (rather than taking *protoregistry.Files concretely) keeps the completeness check testable without touching the global registry.

type DrainReport added in v0.1.46

type DrainReport struct {
	// Ended counts the streams the soft phase ended at once: every stream
	// that had not claimed it, and every stream that arrived after the drain
	// began.
	Ended int
	// Claimed counts the streams the soft phase left running because they
	// asked to say goodbye (Draining, OnDrain).
	Claimed int
	// HardEnded counts the claimed streams still running at the deadline
	// minus DrainReserve, and so ended by the drain.
	HardEnded int
	// ConnsClosed counts the hijacked connections the drain closed for
	// handlers that ignored their ended context.
	ConnsClosed int
	// StillRunning counts the streams whose handlers had not returned when
	// the report was taken; after Drain, the ones the budget ran out on.
	StillRunning int
}

DrainReport is what a drain did to a registry's streams: the counts the shutdown log reports for the listener phase.

type Drainer added in v0.1.46

type Drainer struct {
	// PreStopDelay is how long the process keeps serving after readiness
	// flips. See Config.PreStopDelay.
	PreStopDelay time.Duration
	// ShutdownTimeout bounds every teardown step after the pre-stop pause.
	// See Config.ShutdownTimeout.
	ShutdownTimeout time.Duration
	// Logger receives the phase lines; nil is slog.Default().
	Logger *slog.Logger
	// Meter records the phases in forge.server.drain.duration; nil is the
	// global OpenTelemetry meter.
	Meter metric.Meter
	// contains filtered or unexported fields
}

Drainer is a process's readiness gate and drain budget: the part of the shutdown sequence Run executes that is not specific to one http.Server. Run uses one; a main that runs its own listener (a worker, a gateway) uses the same type so its probes and budget behave identically.

The zero value is usable. Both durations follow Config's rule: zero is the default (5s / 30s), negative is none — no pause, or a shutdown window that is over as it opens. It is safe for concurrent use: readiness handlers read it from request goroutines while the shutdown path writes it.

It writes the shutdown's own phase lines (drain_phases.go): Begin the readiness flip and the pre-stop pause, Complete the total.

func NewDrainer added in v0.1.46

func NewDrainer(cfg Config) *Drainer

NewDrainer returns a Drainer carrying cfg's drain budget.

func (*Drainer) Begin added in v0.1.46

func (d *Drainer) Begin()

Begin flips readiness to "draining" and then blocks for PreStopDelay so load balancers observe the failing probe before anything stops serving. The listener stays open for the whole pause — that is the point: requests already in flight, and requests a balancer has not yet stopped sending, are still answered normally.

It logs the flip ("drain: readiness flipped to draining", phase readiness) and the end of the pause (phase pre_stop, with its elapsed).

func (*Drainer) Complete added in v0.1.46

func (d *Drainer) Complete()

Complete ends the shutdown's log: one "drain: complete" line with the total since Begin and the ShutdownTimeout it ran against, recorded as the phase total. Call it when the teardown is done and before telemetry is flushed, so the point is exported. It does nothing before Begin or after the first call.

func (*Drainer) Draining added in v0.1.46

func (d *Drainer) Draining() bool

Draining reports whether Begin has run.

func (*Drainer) MarkReady added in v0.1.46

func (d *Drainer) MarkReady()

MarkReady opens the readiness gate: the listener is bound and accepting. /readyz still runs its dependency checks after this.

func (*Drainer) Phase added in v0.1.46

func (d *Drainer) Phase(name string, start time.Time, msg string, attrs ...any) time.Time

Phase records one phase of the shutdown this drainer began — Run's workers, operators and teardown, or a supervisor's own — begun at start: one INFO line, msg plus attrs, with `phase` and its `elapsed`, and the duration in forge.server.drain.duration, through the drainer's Logger and Meter. It returns the phase's end, the next one's start. Before Begin it records nothing and returns now.

func (*Drainer) ReadinessHandler added in v0.1.46

func (d *Drainer) ReadinessHandler(checks []ReadyCheck, timeout time.Duration, logger *slog.Logger) http.HandlerFunc

ReadinessHandler builds GET /readyz over this gate: 503 "not_ready" before MarkReady, 503 "draining" once Begin has run, and otherwise the live dependency checks — 200 when every one passes. All checks share one timeout, so the probe's worst case stays bounded however many are registered. The draining answer short-circuits the checks: no dependency can make a replica that is shutting down routable again. See readiness.go for the response's no-leak contract.

func (*Drainer) Ready added in v0.1.46

func (d *Drainer) Ready() bool

Ready reports whether the gate is open: bound, and not draining.

func (*Drainer) ShutdownContext added in v0.1.46

func (d *Drainer) ShutdownContext() (context.Context, context.CancelFunc)

ShutdownContext returns the context that bounds every teardown step after the pre-stop pause. Derived from context.Background, not from the run context: that one is already cancelled by the signal that started the drain.

type Edge added in v0.1.46

type Edge struct {
	CORS            func(origins []string, allowCredentials bool) func(http.Handler) http.Handler
	SecurityHeaders func(production bool) func(http.Handler) http.Handler
	RequestID       func() func(http.Handler) http.Handler
	Outer           func(http.Handler) http.Handler
}

Edge is the HTTP layers a listener puts in front of its routes: CORS, security headers, the request id and an outermost wrapper of the project's own. Each is a factory the project supplies — the matching logic stays in the project's middleware — and Config decides which are on. serverkit.Run builds its edge from the Server's fields; a listener in a composition graph builds one directly for ListenerDeps.Edge.

func (Edge) Wrap added in v0.1.46

func (e Edge) Wrap(cfg Config) (func(http.Handler) http.Handler, error)

Wrap returns the edge for cfg: CORS when cfg.CORSEnabled, security headers when cfg.SecurityHeaders, then the request id outermost, inside Outer. CORS and security headers are security controls, so one cfg turns on but whose factory is nil is an error — never a listener serving with the layer silently absent.

type FailurePolicy

type FailurePolicy int

FailurePolicy governs what Run does when a supervised background component fails: a worker's Start/RunContext returning a non-cancellation error, or Server.RunOperators returning an error. The zero value is FailProcess — fail loud. A pod that restarts with a clear error in its termination log is operable; a pod that keeps serving HTTP while its workers are silently dead is a data-loss incident with a delay timer.

It governs SUPERVISED components only. The pprof side-listener is not one: a pprof bind failure is always logged and always survived, under either policy. pprof does no work the service owes anyone, and losing the service to a busy debug port is a worse outcome than losing the debug port.

const (
	// FailProcess (the zero value / default) cancels the run context on
	// the first component failure: graceful shutdown executes and Run
	// returns the component's error, so the supervisor (Kubernetes,
	// systemd, …) restarts the process loudly.
	FailProcess FailurePolicy = 0

	// Ignore logs the component error and keeps serving — the pre-2026
	// behaviour. Explicit opt-in for deployments where a worker is
	// genuinely best-effort and a dead one must not take down the API.
	Ignore FailurePolicy = 1
)

type FlowCheck

type FlowCheck struct {
	// Name labels the check in the /flow-health JSON (e.g. "daemon-flow").
	Name string
	// Check runs the assertion. It must NOT include per-entity / per-user
	// detail in the returned Summary — that's the public-status contract.
	Check func(ctx context.Context) FlowResult
}

FlowCheck is one named app-flow health assertion mounted at /flow-health. Check runs the invariant internally (the service already holds the access) and returns a FlowResult: ok = the invariant holds, Summary = a TERSE, non-sensitive aggregate ("2 daemons, 0 unattached") safe to expose anonymously. A nil error with ok=false is a clean "unhealthy"; a non-nil error means the check itself couldn't run (also reported as unhealthy, with the error as the summary).

type FlowResult

type FlowResult struct {
	OK      bool
	Summary string
}

FlowResult is the outcome of one FlowCheck: whether the invariant holds and a terse, non-sensitive aggregate summary.

type Listener added in v0.1.46

type Listener struct {
	// contains filtered or unexported fields
}

Listener is an HTTP listener component. Build it with NewListener.

func NewListener added in v0.1.46

func NewListener(deps ListenerDeps) (*Listener, error)

NewListener mounts deps.Routes with deps.Chain's handler options and builds the listener; it binds nothing until Listen or Run. It refuses — returns an error — when a required dep is missing, and when a mounted service declares an audience other than the chain's.

func NewProbeListener added in v1.0.0

func NewProbeListener(deps ProbeListenerDeps) (*Listener, error)

NewProbeListener builds a Listener that serves nothing but the process's probe surface (liveness, readiness, /metrics, and Probes.Handle's routes): what a process with no API listener — a worker, an operator, a gateway's outbound half — answers its kubelet on. It takes the address over from the boot probes like any Listener, has no chain and no routes, and is the one listener that never authenticates a request, because it serves none.

func (*Listener) Addr added in v0.1.46

func (l *Listener) Addr() net.Addr

Addr is the bound address — the real port when Config.Addr asked for ":0" — or nil before Listen.

func (*Listener) Audience added in v0.1.46

func (l *Listener) Audience() string

Audience is the audience the listener's chain admits; "" for serverkit.Run's listener over a Server with no Chain.

func (*Listener) Close added in v0.1.46

func (l *Listener) Close(context.Context) error

Close ends everything the listener still holds, now: every bound stream, every hijacked connection, every open connection. After a completed Drain there is nothing left and it returns at once.

func (*Listener) Drain added in v0.1.46

func (l *Listener) Drain(ctx context.Context) error

Drain stops the listener accepting and drains it inside ctx's deadline: the unary requests through http.Server.Shutdown and the long-lived streams soft-then-hard (DrainHTTP), writing the "drain: listener drained" phase. Run then returns. Draining a listener that never bound does nothing.

func (*Listener) Listen added in v0.1.46

func (l *Listener) Listen(ctx context.Context) error

Listen binds the listener's address without serving yet, so a caller can learn the bound address, or report a bind failure, before anything runs. With TLS configured it first loads and validates the certificate pair, so a bad one is a Listen error: the listener never binds and never reports Ready. Run calls it when it has not been called. Idempotent.

When ctx carries the boot probes (StartBootProbes) bound to the same address, Listen takes the address over from them.

func (*Listener) Ready added in v0.1.46

func (l *Listener) Ready(context.Context) error

Ready reports nil while the listener is bound and not draining — its half of a process's readiness.

func (*Listener) Run added in v0.1.46

func (l *Listener) Run(ctx context.Context) error

Run binds (unless Listen already did) and serves until Drain stops the listener — it then returns nil — or ctx ends, which stops it hard (Close). A serve failure is returned. Call it once.

func (*Listener) Services added in v0.1.46

func (l *Listener) Services() []string

Services returns the proto services the listener mounted.

func (*Listener) Streams added in v0.1.46

func (l *Listener) Streams() *Streams

Streams is the listener's registry of long-lived streams.

type ListenerConfig added in v0.1.46

type ListenerConfig struct {
	// Addr is the address to bind (":8080", "127.0.0.1:0"). Required.
	Addr string

	// TLSCertPath and TLSKeyPath serve TLS when both are set. The pair is
	// loaded by Listen, before the address is bound: a missing or invalid
	// certificate fails boot, never a listener that already reported Ready.
	TLSCertPath string
	TLSKeyPath  string

	// ReadHeaderTimeout bounds reading a request's headers. Zero is 10s.
	ReadHeaderTimeout time.Duration
	// ReadTimeout bounds reading a whole request, body included — over
	// HTTP/2 each stream's request body, so a listener whose clients hold
	// long client or bidi streams sets it negative. Zero is 30s.
	ReadTimeout time.Duration
	// IdleTimeout is how long a keep-alive connection may sit idle. Zero is
	// 120s.
	IdleTimeout time.Duration
	// HTTP2MaxConcurrentStreams caps the streams one HTTP/2 connection may
	// open. Zero is net/http's default.
	HTTP2MaxConcurrentStreams int
	// WriteStallTimeout bounds one write blocked on a client that is not
	// reading (WriteStallGuard). Zero is DefaultWriteStallTimeout.
	WriteStallTimeout time.Duration

	// DrainReserve is the part of the drain budget kept back after the
	// long-lived streams are ended (Streams.DrainReserve). Zero is
	// DefaultDrainReserve.
	DrainReserve time.Duration
	// RedialOnDrain names the procedures or services whose drained streams
	// end Unavailable instead of OK (Streams.RedialOnDrain).
	RedialOnDrain []string
}

ListenerConfig is one listener's own block: where it binds and how its connections behave. Durations follow Config's rule — zero is the default, negative is none. Config.Listener projects a process Config onto it.

type ListenerDeps added in v0.1.46

type ListenerDeps struct {
	// Config is the listener's own block: address, TLS, timeouts, drain.
	Config ListenerConfig
	// Chain is the interceptor chain every Connect route is built with, and
	// the audience every mounted service must declare. Required.
	Chain *Chain
	// Routes are what the listener serves. Required.
	Routes Routes
	// Logger receives the listener's lines and its drain phase; nil is
	// slog.Default().
	Logger *slog.Logger
	// Edge wraps the routes' handler — CORS, security headers, a request id.
	// The Front routes bypass it. nil is no edge.
	Edge func(http.Handler) http.Handler
	// Front are routes served IN FRONT of Edge and of the chain, by exact
	// pattern: probes and /metrics, which a kubelet or a scraper reaches
	// without CORS or credentials. "/" is not allowed: it is the routes'.
	Front map[string]http.Handler
	// REST serves a vanguard REST transcoder over the mounted services
	// (forge.yaml api.rest) in place of the bare Connect mux.
	REST bool
}

ListenerDeps configures a Listener. Config, Chain and Routes are required.

type Omit added in v0.1.46

type Omit string

Omit drops one defaulted slot from a Chain. A nil slot means "forge's default", so leaving a layer out has to be said: ChainDeps{Audit: serverkit.NoAudit}. An Omit names its slot, and NewChain rejects one given for another slot (NoTrace in Audit).

It satisfies connect.Interceptor only so it fits the slot's type; NewChain never installs it.

const (
	// NoTrace opens no server span for the chain's RPCs.
	NoTrace Omit = "trace"
	// NoDeadline gives the chain's RPCs no server deadline and no slow-call
	// watchdog: a wedged handler then holds its request until the client
	// gives up, and nothing reports it while it does.
	NoDeadline Omit = "deadline"
	// NoAudit writes no audit event for the chain's RPCs.
	NoAudit Omit = "audit"
	// NoActivity writes no activity event for the chain's RPCs, whatever
	// they declare.
	NoActivity Omit = "activity"
	// NoStreamDrain leaves server and bidi streams unbound to the listener's
	// drain: a rollout then waits for them for the whole shutdown budget and
	// cuts them without a status.
	NoStreamDrain Omit = "stream-drain"
	// NoValidate skips protovalidate: requests reach the handler with their
	// buf.validate rules unchecked.
	NoValidate Omit = "validate"
)

The slots a Chain can drop. Auth has none: a chain always authenticates.

func (Omit) OmittedSlot added in v1.0.0

func (o Omit) OmittedSlot() string

OmittedSlot names the slot o drops. It is how observe.Chain, which cannot import serverkit, recognises NoDeadline in observe.Deps.Deadline.

func (Omit) WrapStreamingClient added in v0.1.46

func (Omit) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc

WrapStreamingClient is a passthrough; NewChain never installs an Omit.

func (Omit) WrapStreamingHandler added in v0.1.46

func (Omit) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc

WrapStreamingHandler is a passthrough; NewChain never installs an Omit.

func (Omit) WrapUnary added in v0.1.46

func (Omit) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc

WrapUnary is a passthrough; NewChain never installs an Omit.

type Operator

type Operator interface {
	Name() string
}

Operator is the minimal contract serverkit needs to count the supervised operators. The actual controller wiring happens inside Server.RunOperators — this interface only carries Name for log lines.

type Pprof added in v0.1.46

type Pprof struct {
	// contains filtered or unexported fields
}

Pprof is the pprof endpoint on a listener of its own, as a component: Run serves it, Close shuts it down. pprof is never mounted on a public handler — its heap, goroutine and profile endpoints can leak memory contents and stall the process — so the address is a loopback one by default (Config.PprofAddr, 127.0.0.1:6060). It is always on because the answer to "what is holding the memory" only exists while the process is alive:

go tool pprof http://localhost:6060/debug/pprof/heap
kubectl port-forward <pod> 6060:6060   # then the line above

A bind failure is logged and the endpoint skipped, never a component failure: a debug surface that can take the service down is worse than none. The declared-metrics schema rides the same listener.

func NewPprof added in v0.1.46

func NewPprof(cfg Config, logger *slog.Logger) *Pprof

NewPprof builds the pprof endpoint for cfg.PprofAddr; an empty address is an endpoint that serves nothing. logger nil is slog.Default().

func (*Pprof) Close added in v0.1.46

func (p *Pprof) Close(ctx context.Context) error

Close shuts the endpoint down within ctx.

func (*Pprof) Run added in v0.1.46

func (p *Pprof) Run(ctx context.Context) error

Run serves pprof until ctx ends or Close. A disabled endpoint, or one whose address is taken, waits for ctx and returns nil.

type ProbeListenerDeps added in v1.0.0

type ProbeListenerDeps struct {
	// Config is the listener's address, TLS and timeouts — the process's
	// server address (Config.Listener()), where a kubelet probes it.
	Config ListenerConfig
	// Probes is the surface it serves. Required.
	Probes *Probes
	// Logger is the listener's logger; nil is slog.Default().
	Logger *slog.Logger
}

ProbeListenerDeps are a probe listener's dependencies.

type ProbePaths added in v1.0.0

type ProbePaths struct {
	// Live answers liveness: 200 "ok" while the process runs, booting
	// included. Empty is [DefaultLivenessPath].
	Live []string
	// Ready answers readiness: the boot probes' 503 until the server is
	// composed, then /readyz's drain gate and dependency checks. Empty is
	// [DefaultReadinessPath].
	Ready []string
}

ProbePaths are the paths a process answers its probes on. Each list's first path is the canonical one, the one a deploy probes; the rest are aliases served alike.

func (ProbePaths) Validate added in v1.0.0

func (p ProbePaths) Validate() error

Validate rejects paths a listener cannot serve as an exact probe route: one not starting with "/", "/" itself (the routes' pattern), a path with a space or a "{" (a ServeMux method or wildcard), and a path given twice.

func (ProbePaths) WithDefaults added in v1.0.0

func (p ProbePaths) WithDefaults() ProbePaths

WithDefaults fills an empty list with its default path.

type Probes added in v0.1.46

type Probes struct {
	// Drainer is the readiness gate and the stop budget: readiness answers
	// 503 before Drainer.MarkReady and once its drain has begun. Required.
	Drainer *Drainer
	// Metrics is the /metrics handler (observe.Setup's); nil serves none.
	Metrics http.Handler
	// Timeout bounds all of one readiness request's checks together; zero
	// is the serverkit default.
	Timeout time.Duration
	// Logger receives check transitions; nil is slog.Default().
	Logger *slog.Logger
	// Paths are the liveness and readiness paths: the process's
	// Config.Probes, so the boot probes and the probe surface answer on the
	// same ones. Empty is /healthz and /readyz.
	Paths ProbePaths
	// contains filtered or unexported fields
}

Probes is a process's probe surface as listener Front routes: liveness and readiness on Paths (/healthz, /readyz by default) over the process's Drainer and its ready checks, /metrics, and any route added with Handle (a /flow-health). It is the root's policy, not a component: one per process, built before the components, and served IN FRONT of a listener's edge so a kubelet or a scraper reaches it without CORS or credentials.

Ready checks may be added after the readiness handler is built — a composition graph knows which of its nodes report Ready only once the root's closure is constructed, after the listener that serves the probe.

func (*Probes) AddReadyCheck added in v0.1.46

func (p *Probes) AddReadyCheck(checks ...ReadyCheck)

AddReadyCheck adds dependency checks to /readyz. Checks with no Name or no Check are dropped. Safe while /readyz is serving.

func (*Probes) Front added in v0.1.46

func (p *Probes) Front() map[string]http.Handler

Front returns the probe routes, for ListenerDeps.Front: liveness and readiness on Paths, /metrics when Metrics is set, and every route added with Handle. Taking it closes the surface to new routes.

func (*Probes) Handle added in v1.0.0

func (p *Probes) Handle(path string, h http.Handler) error

Handle adds a route to the probe surface — a health endpoint of the process's own, such as /flow-health, served beside the probes and, like them, in front of the edge: no CORS, no credentials. So it must be safe to answer anonymously.

Add routes before a listener takes the surface (Front): a listener mounts its routes once, when it is built. In a composition graph that is any time during the root's build for a root whose probes a probe listener serves (it is built after the closure), and before the API listener node otherwise. A late route, a path the surface already serves, or a path that is not a plain "/…" one is an error.

func (*Probes) ReadyChecks added in v0.1.46

func (p *Probes) ReadyChecks() []ReadyCheck

ReadyChecks returns the checks /readyz runs.

type ReadyCheck

type ReadyCheck struct {
	// Name identifies the dependency in the /readyz JSON ("database",
	// "cache", "queue"). It is the only per-check detail that goes out.
	Name string
	// Check probes the dependency. It receives a context already bounded
	// by Config.ReadinessTimeout and must respect it.
	Check func(ctx context.Context) error
}

ReadyCheck is one DEPENDENCY probe evaluated on every /readyz request.

Check must be CHEAP — it runs on the kubelet's probe interval, every few seconds, forever. A connection-pool ping is the intended shape. Anything that touches application data, iterates rows, or calls a third party belongs in a FlowCheck (/flow-health), which is polled deliberately rather than continuously.

A non-nil error means NOT READY. The error is logged server-side (at WARN when the check starts failing) and never returned to the caller; only Name appears in the response.

func DBReadyCheck

func DBReadyCheck(name string, db *sql.DB) ReadyCheck

DBReadyCheck returns a ReadyCheck that pings a database pool.

This is the check whose absence made /readyz a lie. db.PingContext takes a connection from the pool and round-trips it, so it fails when the server is unreachable, when credentials are wrong, and when the pool is exhausted — every way "this replica cannot serve" actually happens.

A nil pool returns the zero ReadyCheck, which AddReadyCheck drops. That is deliberate: a project with no DATABASE_URL configured has no database dependency to be unready about, and a nil-pool check that reported FAILURE would make such a process permanently unroutable.

type Routes added in v0.1.46

type Routes interface {
	// Register mounts every route on mux, building each Connect handler with
	// opts — the listener's chain — and returns the fully qualified proto
	// service names it mounted ("acme.billing.v1.BillingService"), which the
	// listener checks against its chain's audience. Plain HTTP routes (a
	// websocket, a login callback) are mounted too and named nowhere.
	Register(mux *http.ServeMux, opts ...connect.HandlerOption) []string
}

Routes is a set of handlers a Listener serves.

type RoutesFunc added in v0.1.46

type RoutesFunc func(mux *http.ServeMux, opts ...connect.HandlerOption) []string

RoutesFunc adapts a function to Routes — the shape of a generated (*app.Components).Mount<Svc> closed over its receiver.

func (RoutesFunc) Register added in v0.1.46

func (f RoutesFunc) Register(mux *http.ServeMux, opts ...connect.HandlerOption) []string

Register calls f.

type Server

type Server struct {
	// Handler is the fully-composed HTTP handler: the mux with all
	// services already mounted (their Connect interceptors already
	// applied via connect.HandlerOption at mount time), plus any
	// REST-transcoder swap already resolved by the caller, plus any
	// /metrics handle the caller mounted. serverkit wraps it with its
	// OWN edge (CORS/security/request-id from Config + the factory
	// fields below) and routes /healthz + /readyz to its own probes
	// IN FRONT of that edge, but never re-mounts services. Required.
	//
	// A composition root that uses the Mux ergonomics below need not set
	// Handler explicitly: when Handler is nil at Run time and Mux is
	// non-nil, Run uses Mux as the handler. Setting Handler is still the
	// way to install a REST-transcoder swap or any wrapper around the raw
	// mux — set Handler to the wrapped value after mounting.
	Handler http.Handler

	// Mux is the composition root's service mux — the *http.ServeMux that
	// mountkit.RegisterService (or this Server's Mount* helpers) mount
	// onto. It is OPTIONAL ergonomics: a composition root can `srv.Mux =
	// http.NewServeMux()`, mount services through Server.Mount (which both
	// registers on Mux AND records the proto service name for the
	// completeness check), and leave Handler nil — Run falls back to Mux.
	// When the caller needs to wrap the mux (REST swap, an outer handler)
	// it builds Mux, mounts, then sets Handler to the wrapped value; Mux
	// stays the mount target and the recorded-names set is unaffected.
	Mux *http.ServeMux

	// HandlerOpts is the shared []connect.HandlerOption (the interceptor
	// chain from observe.Chain wrapped via connect.WithInterceptors, plus
	// the ReadMaxBytes/SendMaxBytes payload limits) that every service's
	// Register call receives. Carried on the Server so the Mount helpers
	// can thread it through without the caller repeating it per service.
	// Callers that mount via mountkit.RegisterService directly pass it
	// themselves and may leave this nil.
	HandlerOpts []connect.HandlerOption

	// Chain is the interceptor chain the services were mounted with
	// (NewChain). Set, it does two things: the Mount helpers build each
	// service with its HandlerOptions when HandlerOpts is nil, and Run's
	// Listener checks every recorded service's declared
	// `(forge.v1.service).audience` against the chain's Authenticator and
	// refuses to serve a mismatch. nil — a Server mounted with hand-built
	// HandlerOpts — skips the audience check.
	Chain *Chain

	// Logger is the root logger the caller already built (the same one
	// it passed into bootstrap so mount-time logs and run-time logs
	// agree). When nil, serverkit builds one from Config (newLogger) and
	// SetDefaults it. Optional.
	Logger *slog.Logger

	// Workers / Operators are the already-constructed, already-selected
	// supervised components. Selection (the old names filter) happened
	// ABOVE serverkit: the caller passes exactly the workers/operators
	// this process should run. serverkit no longer filters by name.
	Workers   []Worker
	Operators []Operator

	// OnShutdown is the caller's teardown (closing the serving DB pool,
	// flushing app-owned buffers). Run calls it after in-flight HTTP has
	// drained and workers and operators have stopped, so nothing it closes
	// is still in use, and before the telemetry flush. Optional; nil is a
	// no-op.
	OnShutdown func(context.Context) error

	// RunOperators starts the controller-runtime manager and blocks
	// until ctx is done. serverkit calls it in a goroutine when
	// len(Operators) > 0, and on no other condition: the caller already
	// decided whether this process runs a manager by deciding whether to
	// populate Operators, and serverkit does not second-guess that from
	// the environment. Nil + non-empty Operators is a config error.
	// Optional when Operators is empty.
	RunOperators func(ctx context.Context, logger *slog.Logger, healthProbeAddr string) error

	// Edge factories: kept as fields (not pure Config) because the
	// concrete middleware still lives in the project's generated
	// pkg/middleware tree and serverkit must not import the project.
	// serverkit owns the GATING (driven by Config: CORSOrigins,
	// SecurityHeaders, Environment); these only supply the wrapper. All
	// optional — nil skips that edge layer.
	CORSMiddleware            func(origins []string, allowCredentials bool) func(http.Handler) http.Handler
	SecurityHeadersMiddleware func(production bool) func(http.Handler) http.Handler
	RequestIDMiddleware       func() func(http.Handler) http.Handler
	HTTPMiddleware            func(http.Handler) http.Handler

	// FlowChecks are the APP-FLOW health assertions this service exposes at
	// GET /flow-health. They are the readiness/liveness analogue for an
	// END-TO-END invariant only this service can assert (it holds the state
	// internally), e.g. "every Ready managed daemon is attached to the
	// gateway". serverkit runs every check on each /flow-health request and
	// returns 200 when ALL pass, 503 when ANY fails, plus a STATUS-ONLY JSON
	// aggregate (per-check name + ok + a terse count summary). It deliberately
	// exposes NO per-entity detail anonymously, so the endpoint is safe to
	// probe from `forge env smoke` (which just curls it) without leaking anything
	// sensitive — gate per-entity detail behind your own auth'd endpoint.
	//
	// Empty (the default) means no /flow-health is mounted, so existing
	// services are unaffected. See FlowCheck + AddFlowCheck.
	FlowChecks []FlowCheck

	// ReadyChecks are the DEPENDENCY probes GET /readyz runs on every
	// request — "can this replica serve right now", as opposed to
	// FlowChecks' "is my end-to-end invariant holding". Leaving this empty
	// makes /readyz report readiness purely on the listener being bound,
	// which is what let a replica with an unreachable database advertise
	// itself as Ready through an entire rollout. Register the pool with
	// AddReadyCheck(DBReadyCheck("database", db)). See readiness.go.
	ReadyChecks []ReadyCheck
	// contains filtered or unexported fields
}

Server carries the already-composed inputs serverkit runs. Service SELECTION — which handlers/workers/operators this process serves — has happened ABOVE serverkit: the caller (the generated cmd-server shim) builds the mux, mounts the selected services on it, constructs the selected workers/operators, and hands the result here. serverkit owns only the uniform lifecycle (listener bind, the HTTP edge, health probes, worker supervision, operator gating, graceful shutdown) and knows nothing about names.

func (*Server) AddFlowCheck

func (s *Server) AddFlowCheck(c FlowCheck)

AddFlowCheck registers an app-flow health check surfaced at /flow-health. A check with a nil Check func or empty Name is ignored so a composition root can pass a conditional builder's result without a guard.

func (*Server) AddOperator

func (s *Server) AddOperator(o Operator)

AddOperator appends a constructed operator to Server.Operators. Like AddWorker it is ergonomics over a direct append; a nil operator is ignored. Remember that a non-empty Operators requires Server.RunOperators to be set (Run rejects the mismatch).

func (*Server) AddReadyCheck

func (s *Server) AddReadyCheck(c ReadyCheck)

AddReadyCheck registers a dependency probe surfaced at /readyz. A check with a nil Check func or an empty Name is ignored, so a composition root can pass a conditional builder's result — including DBReadyCheck on a nil pool — without a guard.

func (*Server) AddWorker

func (s *Server) AddWorker(w Worker)

AddWorker appends a constructed worker to Server.Workers. It is pure ergonomics for the composition root — `srv.AddWorker(w)` reads cleaner than re-slicing srv.Workers — and is equivalent to appending directly. A nil worker is ignored so a composition root can pass the result of a conditional builder without a guard.

func (*Server) AddWorkers

func (s *Server) AddWorkers(workers []Worker)

AddWorkers registers an already-selected worker set for supervision.

Selection happened above serverkit — a per-worker subcommand hands a one-element slice, the all-services command hands its full set. serverkit supervises exactly what it is given and never filters by name.

func (*Server) Mount

func (s *Server) Mount(connectPath string, svc any, mountOpts ...mountkit.Option)

Mount registers a single forge Connect service onto Server.Mux AND records its proto service name for the boot-time completeness check.

It is the composition-root convenience that fuses the two steps the explicit-composition shape always does together: mount the handler, then remember that this proto service is accounted for. Internally it calls mountkit.RegisterService(s.Mux, svc, s.HandlerOpts, ...) — the SAME mount mechanism a caller would use by hand — then s.Mounted(connectPath) so RequireMounted can verify completeness later.

  • connectPath is the fully-qualified proto service path (e.g. "acme.billing.v1.BillingService") — the DECLARED proto identity RequireMounted walks the registry for. This is recorded as-is; pass the generated ConnectPath constant, not the kebab runtime name.
  • svc must implement mountkit.Registrar (mountkit panics otherwise — a non-service in the mount loop is a boot-time programming error).
  • mountOpts forward to mountkit: mountkit.WithHandlerOptions adds Connect handler options to this service alone, after HandlerOpts (so the shared chain wraps them); mountkit.WithHTTPStack wraps its plain-HTTP routes.

Mount panics if Server.Mux is nil: a composition root calling Mount has opted into the Mux ergonomics, so a nil Mux is a wiring bug to surface loudly at boot, not mount silently nowhere.

func (*Server) Mounted

func (s *Server) Mounted(name string)

Mounted records that the proto service named name (its fully-qualified Connect path, e.g. "acme.billing.v1.BillingService") has had its handler mounted on this Server. It is the recording seam for the boot-time completeness check: the Mount helpers call it after a successful RegisterService, and a composition root that mounts through mountkit.RegisterService DIRECTLY calls it itself with the same name so RequireMounted can later verify nothing declared was left unmounted.

name is the DECLARED proto identity — the same string that appears in the proto FileDescriptor's services list and that RequireMounted walks the registry for. Recording the kebab runtime name or the procedure path instead would make the completeness check compare apples to oranges, so callers must pass the fully-qualified proto service name. Empty names are ignored.

func (*Server) MountedNames

func (s *Server) MountedNames() []string

MountedNames returns the sorted set of proto service names recorded via Mounted. Exposed for diagnostics and tests; the run path uses RequireMounted, not this.

func (*Server) RecordMounted

func (s *Server) RecordMounted(connectPaths []string)

RecordMounted records every Connect path a typed mount returned, so the boot-time completeness gate can tell mounted services from declared ones.

It is the loop that always follows a mount call. Keeping it as a step rather than folding it into the mount itself matters: a composition root mounts through its OWN typed Mount<Svc> methods (compile-time selection, no string lookup), so serverkit never sees the mount happen and cannot record the result on the caller's behalf.

func (*Server) RequireComplete

func (s *Server) RequireComplete(inv []inventory.ComponentInfo) error

RequireComplete is the boot-time mount-completeness gate, scoped to the project's OWN declared services: every service in inv must have been recorded as mounted, or boot fails. A composition root that deliberately mounts a SUBSET (a per-service subcommand) simply does not call this.

The scope is exactly the inventory's services — never every service in their proto files, nor protoregistry.GlobalFiles. GlobalFiles also carries framework and transitively-imported services this binary does not serve (grpc.health.v1.Health, which serverkit mounts itself, and the OTel collector services the SDK pulls in). And a proto file can declare a service forge does not manage (`// forge:unmanaged`) beside one it does; the inventory leaves the first out, so a gate that widened to the whole file would refuse to boot over a service that is not forge's to mount.

A service the inventory names but the binary never linked is a hard error: the inventory and the generated packages must agree, and a disagreement means the gate would be checking something other than what the binary serves.

func (*Server) RequireMounted

func (s *Server) RequireMounted(src DescriptorSource) error

RequireMounted is the boot-time completeness guardrail: it verifies that every DECLARED proto service has a mounted handler on this Server, and returns a clear error naming any that don't.

Why this exists

The retired generated DI injector gave a COMPILE-TIME guarantee that you wired every service — the generated wiring referenced each one by name, so a missing service was a build error. Explicit per-server composition trades that for hand-written mount calls, which a human (or a stale codegen pass) can forget. RequireMounted restores the guarantee at BOOT instead of compile: it walks the proto service descriptors the binary links and fails fast if any declared service was never mounted, so a forgotten Mount call is a loud startup error rather than a silent 404 in production.

How it walks declarations

It ranges the supplied descriptor source — a protoregistry.Files (use protoregistry.GlobalFiles for the process-wide set the binary linked, or a scoped *protoregistry.Files in a test) — collecting every protoreflect.ServiceDescriptor's full name. Each is compared against the names recorded via Server.Mounted (see Mount). Any declared service with no recording is reported.

ServeMux does not expose its registered patterns, so completeness is checked against EXPLICITLY RECORDED names (Server.Mounted), never by introspecting the mux — the recording seam is the source of truth.

Returns nil when every declared service is mounted (including the trivial no-services case). The error, when non-nil, names every missing service sorted for stable output.

type SlowCall added in v1.0.0

type SlowCall = observe.SlowCall

SlowCall is one watchdog report of a unary RPC that ran past SlowAfter. It is observe.SlowCall.

type Streams added in v0.1.46

type Streams struct {
	// DrainReserve is how much of the shutdown budget Drain keeps back after
	// it ends the streams that are still open: streams that claimed the soft
	// phase run until the deadline minus this. Zero means
	// DefaultDrainReserve; it is never more than half the budget left when
	// Drain starts, so a soft phase always exists.
	DrainReserve time.Duration

	// RedialOnDrain names the Connect procedures ("/pkg.v1.Svc/Method") or
	// whole services ("pkg.v1.Svc") whose streams the drain ends with
	// Unavailable instead of OK — for peers that redial on Unavailable. See
	// StreamDrainInterceptor for the client contract.
	RedialOnDrain []string

	// Logger receives the "drain: listener drained" line DrainHTTP writes
	// for this listener; nil is slog.Default().
	Logger *slog.Logger
	// Meter records that phase (listener) in forge.server.drain.duration;
	// nil is the global OpenTelemetry meter.
	Meter metric.Meter
	// contains filtered or unexported fields
}

Streams is a listener's registry of long-lived streams. The zero value is ready to use; every method is safe for concurrent use.

func NewStreams added in v0.1.46

func NewStreams() *Streams

NewStreams returns an empty registry.

func StreamsFrom added in v0.1.46

func StreamsFrom(ctx context.Context) *Streams

StreamsFrom returns the registry installed for the request ctx belongs to, or nil.

func (*Streams) Active added in v0.1.46

func (s *Streams) Active() int

Active reports how many streams are bound: the long-lived handlers still running.

func (*Streams) BeginDrain added in v0.1.46

func (s *Streams) BeginDrain() (ended, left int)

BeginDrain starts the soft phase: every Draining channel closes, every bound stream that did not claim the soft phase is ended now, and every stream bound from now on is ended at once. It returns how many streams this call ended and how many claimed streams it left running. Idempotent; NewHTTPServer registers it to run at the start of http.Server.Shutdown.

func (*Streams) Bind added in v0.1.46

func (s *Streams) Bind(parent context.Context) (ctx context.Context, release context.CancelFunc)

Bind binds a long-lived handler's context to the drain. The returned context is cancelled, with cause svcerr.ErrServerDraining, when the drain ends this stream (see the phases above); a stream bound after the drain began is ended at once rather than pinning a server that is going away. release frees the binding and must be called when the handler returns; calling it more than once is harmless.

func (*Streams) CloseHijacked added in v0.1.46

func (s *Streams) CloseHijacked() int

CloseHijacked closes every connection a still-bound handler took over with Hijack (a websocket), and reports how many it closed. It is the last resort for a handler that ignores its ended context: its blocked read returns, so it unwinds and its deferred cleanup runs before the process exits, instead of the exit dropping the TCP connection with the cleanup never run. http.Server.Shutdown does not touch hijacked connections.

func (*Streams) Drain added in v0.1.46

func (s *Streams) Drain(ctx context.Context) error

Drain runs the drain inside ctx's deadline: BeginDrain; wait for the claimed streams until the deadline minus DrainReserve; End whatever is still bound; give those handlers half the reserve to return; CloseHijacked for the ones that did not; then wait for them until the deadline. It returns an error naming how many were still running when ctx ended. Without a deadline the soft phase lasts until every stream has returned or ctx is cancelled.

It is the long-lived half of a listener's drain; DrainHTTP runs it beside http.Server.Shutdown, which drains the unary half.

func (*Streams) End added in v0.1.46

func (s *Streams) End() int

End ends every bound stream now, claimed or not — the hard phase. It returns how many streams this call ended. Idempotent.

func (*Streams) Install added in v0.1.46

func (s *Streams) Install(h http.Handler) http.Handler

Install makes s the registry for every request h serves: the one LongLived routes and StreamDrainInterceptor bind to, found through the request's context. It also serves each request with a body AbortReceive can end, which is how the drain ends a stream parked in Receive (receive_abort.go). NewHTTPServer installs its Streams this way; a process with its own server stack wraps its handler with it.

func (*Streams) Report added in v0.1.46

func (s *Streams) Report() DrainReport

Report returns what the drain has done so far.

func (*Streams) Wait added in v0.1.46

func (s *Streams) Wait(ctx context.Context) bool

Wait blocks until no stream is bound or ctx is done, and reports whether the registry emptied.

type Worker

type Worker interface {
	// Name is the worker's stable identifier — used in log lines and
	// the Server.Workers iteration order.
	Name() string

	// Start runs the worker's main loop. It must return when ctx is
	// done. serverkit calls Start once per worker in its own goroutine
	// and never restarts.
	Start(ctx context.Context) error

	// Stop performs graceful shutdown bounded by the supplied ctx
	// (typically Config.ShutdownTimeout). serverkit calls Stop after
	// the worker's Start ctx has been cancelled and the supervisor
	// WaitGroup has drained.
	Stop(ctx context.Context) error
}

Worker is the runtime contract for a long-running background task. The generated WorkerInstance in pkg/app satisfies it directly.

Directories

Path Synopsis
Package drainbudget is the one reading of a process's two drain knobs, PRE_STOP_DELAY and SHUTDOWN_TIMEOUT: how a value is written, what zero and a negative value mean, and the defaults.
Package drainbudget is the one reading of a process's two drain knobs, PRE_STOP_DELAY and SHUTDOWN_TIMEOUT: how a value is written, what zero and a negative value mean, and the defaults.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL