Skip to content

Routers and Listeners

wiki edited this page Sep 4, 2026 · 1 revision

Routers and listeners

A router owns one listener, one base path and one route table. An application has at least one — default — and may have any number more.

app := rex.New(rex.WithConfig(&rex.Config{
	DefaultRouter: rex.RouterConfig{Addr: ":8080"},
}))

if err := app.CreateRouter("admin", rex.RouterConfig{Addr: ":9090"}); err != nil {
	return err
}

_ = app.RegisterRoute(route.New("GET", "/api/users", listUsers))              // :8080
_ = app.RegisterRouteToRouter(route.New("GET", "/dashboard", dash), "admin")  // :9090

Why more than one

Blast radius and reachability. An admin panel on a separate port can be firewalled to a VPN while the API faces the internet. Health and metrics endpoints on their own listener stay answerable when the public listener is saturated — which is exactly when you need them.

Different limits. A public listener wants a 4 MiB body cap and a 30s read timeout; a metrics endpoint wants 4 KiB and 5s. One RouterConfig per router makes that expressible.

Different documents. The OpenAPI generator includes or excludes routes based on which router serves them, so an internal-only listener's routes stay out of the public specification.

Extensions create their own: rextension-health and rextension-metric each put their endpoints on a dedicated router by default.

Creating one

err := app.CreateRouter("metrics", rex.RouterConfig{
	Addr:         ":9091",
	BaseURL:      "/",
	ReadTimeout:  5 * time.Second,
	MaxBodyBytes: 4 << 10,
})
if err != nil && !errors.Is(err, rex.ErrRouterExists) {
	return err
}

Tolerate ErrRouterExists. Two extensions may both want a router called metrics, and whichever runs second should reuse it rather than abort startup. Note that the first creator's configuration wins — the second one's is discarded.

A router may be created before Run or from an extension's OnInitialize / OnStart. Routes may name a router that does not exist yet: the name is resolved when the tables are built, and an unknown one is reported from Run with the call site of the registration that named it.

The two phases

Every router is declared and then built.

Declared — between creation and Freeze, routes and middleware are collected into ordinary slices under a mutex. Nothing serves.

BuiltFreeze composes each route's middleware chain, builds the trie, and publishes the result by atomic swap. From then on ServeHTTP reads the table without locking, and Register / Use return ErrRouterFrozen.

The framework calls Freeze for you, in step 9 of Lifecycle, in sorted router-name order so log output is reproducible.

Binding

Listeners bind synchronously, in sorted name order, in step 10 — after every table is frozen. A bind failure is returned from Run, and every listener already bound is stopped, so a partially-bound application does not keep serving on some ports while reporting a startup failure.

if err := app.Run(); err != nil {
	// "listen tcp :8080: bind: address already in use"
}

Use Addr: "127.0.0.1:0" to bind a free port, then read the one actually bound:

addr := app.Routers()["default"].Addr()

Routers() is empty before Run and returns the built routers by name after.

The Router interface

type Router interface {
	LifecycleHook

	Name() string
	BaseURL() string          // normalised: no trailing slash, "" for root

	Register(rt route.Route) error
	Routes() []route.Route
	Use(mw Middleware, priority int) error
	UsePerRoute(f PerRouteMiddleware, priority int) error

	Freeze() error
	Frozen() bool

	ServeHTTP(w http.ResponseWriter, r *http.Request)
	Start() error             // binds, synchronously
	Stop() error              // graceful, idempotent
	Addr() string             // bound address, or the configured one

	Subscribe(eventType string, handler func(event.Event))
	PrintRootsTree()
}

Application code rarely touches this — app.RegisterRoute and friends go through it. It is here because Routers() hands them back, and because PrintRootsTree() is a genuinely useful debugging call: it logs the route table as a tree.

Middleware scope

Call Applies to
app.Use(mw) every router, current and future
app.UseOnRouter(name, mw, priority) one named router
app.UsePerRoute(f, priority) routes where the factory returns non-nil, on every router
app.UsePerRouteOn(name, f, priority) the same, limited to one router
app.UsePerRouter(f, priority) routers where the factory returns non-nil

The router named need not exist yet. See Middleware.

Shutdown

Stop() on a router is graceful and idempotent. The framework stops every listener before running OnStop hooks, so nothing new arrives while extensions are tearing down — and a hook that stops a router again is harmless.

Clone this wiki locally