surf
HTTP routing for SummerCMS: collects plugin routes and named middleware into a net/http ServeMux with constraints, rate limiting, body limits, CORS and panic recovery, and provides the serve and route:list commands.
import "git.golem15.com/golem15/summercms/modules/surf"
Overview
surf turns the routes that plugins declare through pact.HasRoutes into one http.Handler. surf.BuildRouter registers the built-in and plugin middleware, walks every plugin's route declarations through a Laravel-style group builder (surf.Router, implementing pact.Router), mounts the cabana admin, and checks every route at boot; surf.Assemble then compiles the result onto a standard library ServeMux. Configuration mistakes such as duplicate routes, unknown middleware names or malformed throttles fail at boot, not on the first request. It is the counterpart of WinterCMS's plugin routes.php files with Laravel's Route::group, ->middleware(), ->where() and throttle middleware.
Features
- Laravel-style route groups:
surf.Router.Groupwith a path prefix and middleware list (surf.Usebuilds the list),surf.Router.Get,surf.Router.Post,surf.Router.Put,surf.Router.Patchandsurf.Router.Delete(the same methods exist on eachsurf.Group), with Go 1.22+ path patterns such as/posts/{id}. - Path constraints:
surf.Router.Where(regex, anchored to the whole segment) andsurf.Router.WhereIn(allow-list) apply to the last declared route; a request that fails a constraint gets a 404.surf.IntParamreads a positive integer path value. - Named middleware from plugins (
pact.HasMiddleware), parameterized middleware used asname:param(pact.HasMiddlewareFactories) and house middleware for the JSON envelope and error handling (pact.HasHouseMiddleware). Duplicate or unknown names fail boot. - Raw groups (
surf.Router.GroupRaw) for routes that must not be wrapped in house middleware, such as webhooks or file streams: house middleware is refused there, the default body limit is skipped and a panic returns a bare 500. - Built-in middleware names:
throttle:<bucket>orthrottle:<max>,<minutes>,body.limit:<bytes>,locale.from-principal, plusbackend(the admin guard) when the admin is enabled. - Fixed-window rate limiting (
surf.FixedWindowLimiter): named buckets from plugins that implementsurf.BucketProvider, or inline limits keyed by the signed-in user, or by client IP for guests. Rejected requests get a 429 withRetry-AfterandX-RateLimit-*headers. The in-processsurf.MemoryStoresits behind thesurf.Storeinterface. - Client IP resolution for limiter keys (
surf.ClientIP) that only trustsX-Forwarded-Forhops when the direct peer is inside a configured trusted proxy range (surf.TrustedProxies). - Every non-raw route runs inside JSON panic recovery (an opaque 500 via wire), gets the request locale from the
Accept-Languageheader (see towel) and a request body cap. Responses are buffered until the handler returns, so a panic never leaves a half-written body. - Path-scoped CORS configured with the same keys as Laravel's
config/cors.php(surf.CORSConfig), including preflight handling. surf.LocaleFromPrincipalswitches the request locale to the signed-in user's preferred locale.- A read-only route table (
surf.Router.Routes) and theserveandroute:listcommands.
Usage
A plugin declares routes, middleware and a rate-limit bucket; the runtime assembles them:
package blog
import (
"net/http"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/golem15/summercms/modules/wire"
)
type Plugin struct{}
func (Plugin) ID() string { return "acme.blog" }
func (Plugin) Requires() []string { return nil }
func (Plugin) Register(*backpack.App) error { return nil }
func (Plugin) Boot(*backpack.App) error { return nil }
func (Plugin) Middlewares() map[string]pact.Middleware {
return map[string]pact.Middleware{
"blog.no-store": func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
},
}
}
func (Plugin) Buckets() map[string]surf.Bucket {
return map[string]surf.Bucket{
"blog.comments": {
Max: 5,
Decay: time.Minute,
Key: func(r *http.Request) string { return "comments|" + surf.ClientIP(r, nil) },
},
}
}
func (Plugin) Routes(r pact.Router) error {
r.Group("/api/blog", surf.Use("throttle:60,1"), func(g pact.Router) {
g.Get("/posts/{id}", showPost)
g.Where("id", `[0-9]+`)
g.Get("/posts/{status}/list", listPosts, "blog.no-store")
g.WhereIn("status", "draft", "published")
g.Post("/posts/{id}/comments", addComment, "throttle:blog.comments", "body.limit:65536")
})
return nil
}
func showPost(w http.ResponseWriter, r *http.Request) {
id, ok := surf.IntParam(r, "id")
if !ok {
http.NotFound(w, r)
return
}
wire.WriteJSON(w, http.StatusOK, map[string]any{"id": id})
}
func listPosts(w http.ResponseWriter, r *http.Request) { wire.WriteJSON(w, http.StatusOK, []string{}) }
func addComment(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) }
The generated application main wires surf in with surf.ServeCommand and surf.RouteListCommand; tests can call surf.Assemble(app, plugins) and drive the returned handler with net/http/httptest.
API reference
| Identifier | Description |
|---|---|
surf.BuildRouter |
Registers built-in and plugin middleware, buckets, routes and the admin, and validates every route without compiling. |
surf.Assemble |
surf.BuildRouter plus compilation into the final http.Handler. |
surf.Router |
The route builder; implements pact.Router. surf.New creates an empty one. |
surf.Group |
A prefixed route collection with inherited middleware. |
surf.Router.RegisterMiddleware |
Stores a named middleware; duplicates fail. |
surf.Router.RegisterMiddlewareFactory |
Stores a parameterized middleware used as name:param. |
surf.Router.Routes |
Returns a copy of the registered routes as surf.RouteInfo values. |
surf.RouteInfo |
Method, pattern, owning plugin, middleware and raw flag of one route. |
surf.Use |
Builds a middleware name list for a group. |
surf.Constraint |
A compiled path-parameter restriction built by surf.Regex or surf.Enum. |
surf.IntParam |
Reads a positive integer path value. |
surf.Bucket |
A named rate limit: maximum attempts, window length and key function. |
surf.BucketProvider |
Implemented by plugins that declare named buckets. |
surf.FixedWindowLimiter |
The rate limiter behind the throttle middleware; surf.NewFixedWindowLimiter creates one. |
surf.Store |
Atomic fixed-window admission; surf.NewMemoryStore is the in-process implementation. |
surf.ClientIP |
Resolves the client IP, honouring trusted proxies. |
surf.TrustedProxies |
Parses http.trusted_proxies into CIDR prefixes. |
surf.CORSConfig |
CORS settings; surf.LoadCORSConfig reads them from config. |
surf.LocaleFromPrincipal |
Middleware that applies the signed-in user's preferred locale. |
surf.ServeCommand |
The serve console command. |
surf.RouteListCommand |
The route:list console command. |
Configuration
surf.BuildRouter reads these keys from the app's compass config:
| Key | Default | Controls |
|---|---|---|
http.body_limits.default_bytes |
none, required | Request body cap in bytes for every non-raw route; body.limit:<bytes> overrides it per route. Must be a whole number of at least 1. |
http.body_limits.upload_bytes |
none, required | Upload body cap in bytes. Must be a whole number of at least 1; it is validated at boot. |
http.trusted_proxies |
empty | List of CIDRs whose X-Forwarded-For header is trusted when resolving the client IP. Malformed entries are skipped. |
http.cors.paths |
empty | Path globs (api/*) that get CORS headers. With no http.cors section no CORS headers are sent. |
http.cors.allowed_origins |
empty | Allowed origins; * allows any. |
http.cors.allowed_origins_patterns |
empty | Regular expressions matched against the origin. |
http.cors.allowed_methods |
empty | Methods sent in Access-Control-Allow-Methods; * allows any. |
http.cors.allowed_headers |
empty | Headers sent in Access-Control-Allow-Headers; * allows any. |
http.cors.exposed_headers |
empty | Headers sent in Access-Control-Expose-Headers. |
http.cors.max_age |
0 |
Preflight cache time in seconds. |
http.cors.supports_credentials |
false |
Sends Access-Control-Allow-Credentials: true. |
http:
body_limits:
default_bytes: 1048576
upload_bytes: 20971520
trusted_proxies: ["10.0.0.0/8"]
cors:
paths: ["api/*"]
allowed_origins: ["https://blog.example.com"]
allowed_methods: ["*"]
allowed_headers: ["*"]
supports_credentials: true
The serve command also opens the database through lagoon and the uploads bucket through attach.OpenBucket, so their settings must be present as well.
CLI commands
| Command | Flags | Description |
|---|---|---|
serve |
--addr (default :8080) |
Opens the database and uploads bucket, assembles the router and serves HTTP until SIGINT or SIGTERM, then shuts down gracefully within 10 seconds. |
route:list |
none | Builds the router the same way serve does, without opening the database or listening, and prints a table of method, pattern, plugin, middleware and raw flag for every route. |
Dependencies
- SummerCMS modules: backpack, bonfire, bouncer, cabana, compass, lagoon (including
lagoon/attach), pact, party, towel, wire. - Third-party:
gocloud.dev/blob(uploads bucket opened byserve). - Standard library:
bytes,context,fmt,math,net,net/http,net/netip,os,os/signal,regexp,strconv,strings,sync,syscall,time.
Testing
go test ./modules/surf/...
The tests use net/http/httptest and in-memory stores and need no external services.