Documentation
¶
Overview ¶
Package serverkit runs an already-composed forge server: HTTP/Connect listener, healthz/readyz probes, the HTTP edge (CORS / security-headers / request-id / h2c), 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, interceptor chain, payload caps …
srv := serverkit.Server{Mux: mux, HandlerOpts: opts, 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).
Lifecycle ¶
Run owns the uniform lifecycle:
- Logger init (Server.Logger, or built from Config when nil).
- 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.
- CORS, security-headers, request-id, and h2c are layered over that top mux from Config gating + the Server factory fields.
- Listener binds, readiness flips true.
- Workers start; the operator manager starts when len(Operators) > 0, which is the caller's decision and the only one.
- SIGINT/SIGTERM → readiness flips false → pre-stop sleep → workers stop → Server.OnShutdown → http.Server shuts down → pprof shuts down.
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, the interceptor chain and its order, 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
- func ApplyDBPoolTuning(db *sql.DB, t DBPoolTuning)
- func AutoMigrate(cfg Config, logger *slog.Logger, migrate func(*sql.DB, *slog.Logger) error) error
- func AutoMigrateContext(ctx context.Context, cfg Config, logger *slog.Logger, ...) error
- func Boot(cfg Config) (*slog.Logger, *http.ServeMux)
- func NewLogger(cfg Config) *slog.Logger
- func OpenDB(ctx context.Context, driver, dsn string, pool DBPoolTuning, p ConnectPolicy) (*sql.DB, error)
- func RESTHandler(mux *http.ServeMux, connectPaths []string, logger *slog.Logger) http.Handler
- func RequireMounted(srv *Server, src DescriptorSource) error
- func RetryConnect(ctx context.Context, p ConnectPolicy, attempt func(ctx context.Context) error) error
- func Run(ctx context.Context, cfg Config, srv Server) error
- func WriteStallGuard(h http.Handler, stall time.Duration) http.Handler
- type BootProbes
- type Config
- type ConnectPolicy
- type ConnectTimeoutError
- type ContextWorker
- type DBPoolTuning
- type DescriptorSource
- type FailurePolicy
- type FlowCheck
- type FlowResult
- type Operator
- type ReadyCheck
- type Server
- func (s *Server) AddFlowCheck(c FlowCheck)
- func (s *Server) AddOperator(o Operator)
- func (s *Server) AddReadyCheck(c ReadyCheck)
- func (s *Server) AddWorker(w Worker)
- func (s *Server) AddWorkers(workers []Worker)
- func (s *Server) Mount(connectPath string, svc any, mountOpts ...mountkit.Option)
- func (s *Server) Mounted(name string)
- func (s *Server) MountedNames() []string
- func (s *Server) RecordMounted(connectPaths []string)
- func (s *Server) RequireComplete(inv []inventory.ComponentInfo) error
- func (s *Server) RequireMounted(src DescriptorSource) error
- type Worker
Constants ¶
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.
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.
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.
Variables ¶
This section is empty.
Functions ¶
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 ¶
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 Boot ¶
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 NewLogger ¶
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. The pool is closed on failure, so a database that never comes up does not leak a half-open pool into the exit path.
func RESTHandler ¶
RESTHandler wraps a Connect mux in a vanguard transcoder over the MOUNTED services' Connect paths, so the same handlers answer REST-shaped URLs in addition to Connect ones.
It returns nil when nothing was mounted, and nil when the transcoder 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 a transcoder that failed to build is 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.
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 ¶
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/h2c 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 WriteStallGuard ¶
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 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.
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. Operators tune this to match their load-balancer probe
// interval — too short causes brief 502s on rollout.
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.
ShutdownTimeout time.Duration
// 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
// 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 ¶
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 ¶
IsDevelopment reports whether this Config runs the development posture.
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
}
DBPoolTuning groups the four sql.DB pool knobs the caller applies to its migration connection via ApplyDBPoolTuning. 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 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 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 ¶
FlowResult is the outcome of one FlowCheck: whether the invariant holds and a terse, non-sensitive aggregate summary.
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 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 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 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/h2c 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
// 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 runs during graceful shutdown after workers stop and
// before the http.Server shuts down — the old Application.Shutdown
// plus (folded in by the caller) the OTel 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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 (e.g. mountkit.WithHTTPStack).
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 ¶
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 ¶
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 ¶
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 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.