Bon is a high-performance HTTP router for Go that uses exact-match indexing, segment tree routing, and a Patricia-style fallback for efficient route matching. It focuses on speed, simplicity, and zero external dependencies.
- Features
- Quick Start
- Installation
- Route Patterns
- Middleware
- Groups and Routes
- HTTP Methods
- File Server
- Custom 404 Handler
- WebSocket, SSE, and HTTP/2 Push Support
- Examples
- Benchmarks
- API Documentation
- Performance Tips
- Requirements
- Testing
- Contributing
- License
- High Performance: Static fast path and segment tree-based dynamic routing
- Zero Dependencies: Uses only Go standard library
- Middleware Support: Flexible middleware at router, group, and route levels
- Standard HTTP Compatible: Works with
http.Handlerinterface - Flexible Routing: Static, parameter (
:param), and wildcard (*) patterns - All HTTP Methods: GET, POST, PUT, DELETE, HEAD, OPTIONS, PATCH, CONNECT, TRACE
- File Server: Built-in static file serving with security protections
- Context Pooling: Efficient memory usage with sync.Pool
- Thread-Safe: Lock-free reads using atomic operations
- Panic Recovery: Recovery middleware available
- WebSocket Ready: Works with standard WebSocket upgrade flows
- SSE Support: Server-Sent Events with proper flushing
- HTTP/2 Push: Supports
http.Pusherwhen the underlying server provides it
package main
import (
"net/http""github.com/nissy/bon/v2""github.com/nissy/bon/v2/middleware"
)
funcmain() {
r:=bon.NewRouter()
// Global middlewarer.Use(middleware.Recovery()) // Panic recovery// Simple router.Get("/", func(w http.ResponseWriter, r*http.Request) {
w.Write([]byte("Hello, Bon!"))
})
// Route with parameterr.Get("/users/:id", func(w http.ResponseWriter, r*http.Request) {
userID:=bon.URLParam(r, "id")
w.Write([]byte("User: "+userID))
})
http.ListenAndServe(":8080", r)
}go get github.com/nissy/bon/v2Routes are matched in the following priority order (highest to lowest):
Static routes - Exact path match
r.Get("/users/profile", handler) // Highest priorityr.Get("/api/v1/status", handler)
Parameter routes - Named parameter capture
r.Get("/users/:id", handler) // Captures id parameterr.Get("/posts/:category/:slug", handler) r.Get("/api/v:version/users", handler) // Captures version from v1, v2, etc.
Wildcard routes - Catch-all or single-segment wildcard pattern
r.Get("/files/*", handler) // Matches the remaining pathr.Get("/api/*", handler) r.Get("/files/*/metadata", handler) // * matches one segment before /metadata
Parameter names are read with bon.URLParam(r, "name"). Wildcard values are not exposed through URLParam.
// Single parameterr.Get("/users/:id", func(w http.ResponseWriter, r*http.Request) {
userID:=bon.URLParam(r, "id")
// Use userID...
})
// Multiple parametersr.Get("/posts/:category/:id", func(w http.ResponseWriter, r*http.Request) {
category:=bon.URLParam(r, "category")
postID:=bon.URLParam(r, "id")
// Use parameters...
})
// Unicode parameter names are supportedr.Get("/users/:name", func(w http.ResponseWriter, r*http.Request) {
name:=bon.URLParam(r, "name")
// Use name...
})Middleware executes in the order it was added, creating a chain:
r:=bon.NewRouter()
// Execution order: Recovery -> CORS -> Auth -> Handlerr.Use(middleware.Recovery()) // 1st - Catches panicsr.Use(middleware.CORS(config)) // 2nd - Handles CORSapi:=r.Group("/api")
api.Use(middleware.BasicAuth(users)) // 3rd - Authenticatesapi.Get("/data", handler) // Finally, the handlerCatches panics and returns 500 Internal Server Error:
r.Use(middleware.Recovery())
// With custom handlerr.Use(middleware.RecoveryWithHandler(func(w http.ResponseWriter, r*http.Request, errinterface{}) {
w.WriteHeader(500)
w.Write([]byte(fmt.Sprintf("Panic: %v", err)))
}))Handles Cross-Origin Resource Sharing:
r.Use(middleware.CORS(middleware.AccessControlConfig{
AllowOrigin: "*",
AllowCredentials: true,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowHeaders: []string{"Authorization", "Content-Type"},
ExposeHeaders: []string{"X-Total-Count"},
MaxAge: 86400,
}))HTTP Basic Authentication:
users:= []middleware.BasicAuthUser{
{Name: "admin", Password: "secret"},
{Name: "user", Password: "pass123"},
}
r.Use(middleware.BasicAuth(users))Request timeout handling:
r.Use(middleware.Timeout(30*time.Second))Groups prefix all routes and inherit middleware from parent groups. Global middleware registered with r.Use(...) is applied to every route at the router level.
r:=bon.NewRouter()
r.Use(middleware.Recovery()) // Global middleware applied to every routeapi:=r.Group("/api")
api.Use(middleware.BasicAuth(users)) // Group middleware// These routes use global Recovery + group BasicAuthapi.Get("/users", listUsers) // GET /api/usersapi.Post("/users", createUser) // POST /api/users// Nested group inherits parent group middlewarev1:=api.Group("/v1")
v1.Get("/posts", listPosts) // GET /api/v1/postsRoutes created with Route() do not inherit group middleware. Global middleware registered with r.Use(...) still applies.
r:=bon.NewRouter()
r.Use(middleware.Recovery()) // Global middleware still appliesapi:=r.Group("/api")
api.Use(middleware.BasicAuth(users))
api.Get("/private", handler) // Uses Recovery + BasicAuth// This route keeps the /api prefix but does not inherit BasicAuthpublic:=api.Route()
public.Get("/public", handler) // Uses Recovery only// Must explicitly add middleware if neededwebhook:=api.Route()
webhook.Use(webhookMiddleware)
webhook.Post("/webhook", handler) // Uses Recovery + webhookMiddlewareAll standard HTTP methods are supported:
r.Get("/users", handler)
r.Post("/users", handler)
r.Put("/users/:id", handler)
r.Delete("/users/:id", handler)
r.Head("/", handler)
r.Options("/", handler)
r.Patch("/users/:id", handler)
r.Connect("/proxy", handler)
r.Trace("/debug", handler)
// Generic method handlerr.Handle("CUSTOM", "/", handler)Serve static files with built-in security:
// Serve files from ./public directory at /static/*r.FileServer("/static", "./public")
// With middlewarer.FileServer("/assets", "./assets", middleware.BasicAuth(users),
middleware.CORS(corsConfig),
)
// In a groupadmin:=r.Group("/admin")
admin.Use(middleware.BasicAuth(adminUsers))
admin.FileServer("/files", "./admin-files")Security features:
- Path traversal protection (blocks
..,./, etc.) - Hidden file protection (blocks
.prefix files) - Null byte protection
- Automatic index.html serving for directories
r:=bon.NewRouter()
// SetNotFound rebuilds the internal middleware chain for 404 responses.r.SetNotFound(func(w http.ResponseWriter, r*http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(404)
w.Write([]byte(`{"error":"not found"}`))
})Bon works with WebSocket, Server-Sent Events (SSE), and HTTP/2 Push through Go's standard interfaces. When using middleware that wraps the ResponseWriter (like the Timeout middleware), access the underlying ResponseWriter through the Unwrap() method when needed.
package main
import (
"net/http""github.com/nissy/bon/v2""github.com/nissy/bon/v2/middleware""github.com/gorilla/websocket"
)
varupgrader= websocket.Upgrader{
CheckOrigin: func(r*http.Request) bool {
returntrue// Configure appropriately for production
},
}
funcmain() {
r:=bon.NewRouter()
r.Use(middleware.Recovery())
r.Get("/ws", func(w http.ResponseWriter, r*http.Request) {
// When using middleware that wraps ResponseWritervarconn*websocket.Connvarerrerror// Try direct upgrade firstconn, err=upgrader.Upgrade(w, r, nil)
iferr!=nil {
// If failed, try through Unwrapifunwrapper, ok:=w.(interface{ Unwrap() http.ResponseWriter }); ok {
conn, err=upgrader.Upgrade(unwrapper.Unwrap(), r, nil)
}
iferr!=nil {
http.Error(w, "WebSocket upgrade failed", http.StatusBadRequest)
return
}
}
deferconn.Close()
// Handle WebSocket connectionfor {
messageType, p, err:=conn.ReadMessage()
iferr!=nil {
break
}
iferr:=conn.WriteMessage(messageType, p); err!=nil {
break
}
}
})
http.ListenAndServe(":8080", r)
}package main
import (
"fmt""net/http""time""github.com/nissy/bon/v2""github.com/nissy/bon/v2/middleware"
)
funcmain() {
r:=bon.NewRouter()
r.Use(middleware.Recovery())
r.Use(middleware.Timeout(30*time.Second))
r.Get("/events", func(w http.ResponseWriter, r*http.Request) {
// Set SSE headersw.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
// Get flushervarflusher http.Flushervarokbool// Try direct cast firstflusher, ok=w.(http.Flusher)
if!ok {
// Try through Unwrapifunwrapper, ok:=w.(interface{ Unwrap() http.ResponseWriter }); ok {
flusher, ok=unwrapper.Unwrap().(http.Flusher)
}
if!ok {
http.Error(w, "SSE not supported", http.StatusInternalServerError)
return
}
}
// Send eventsticker:=time.NewTicker(1*time.Second)
deferticker.Stop()
for {
select {
case<-r.Context().Done():
returncaset:=<-ticker.C:
fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339))
flusher.Flush()
}
}
})
http.ListenAndServe(":8080", r)
}package main
import (
"net/http""github.com/nissy/bon/v2""github.com/nissy/bon/v2/middleware"
)
funcmain() {
r:=bon.NewRouter()
r.Use(middleware.Recovery())
r.Get("/", func(w http.ResponseWriter, r*http.Request) {
// Get pushervarpusher http.Pushervarokbool// Try direct cast firstpusher, ok=w.(http.Pusher)
if!ok {
// Try through Unwrapifunwrapper, ok:=w.(interface{ Unwrap() http.ResponseWriter }); ok {
pusher, ok=unwrapper.Unwrap().(http.Pusher)
}
}
// Push resources if availableifpusher!=nil {
// Push CSS and JS filespusher.Push("/static/style.css", &http.PushOptions{
Header: http.Header{
"Content-Type": []string{"text/css"},
},
})
pusher.Push("/static/app.js", &http.PushOptions{
Header: http.Header{
"Content-Type": []string{"application/javascript"},
},
})
}
// Serve main contentw.Header().Set("Content-Type", "text/html")
w.Write([]byte(` <!DOCTYPE html> <html> <head> <link rel="stylesheet" href="/static/style.css"> <script src="/static/app.js"></script> </head> <body> <h1>Hello with HTTP/2 Push!</h1> </body> </html> `))
})
// Serve static filesr.FileServer("/static", "./static")
// Note: HTTP/2 requires TLShttp.ListenAndServeTLS(":8443", "cert.pem", "key.pem", r)
}For Go 1.20 and later, you can use http.ResponseController which automatically handles the Unwrap() method:
funcsseHandler(w http.ResponseWriter, r*http.Request) {
rc:=http.NewResponseController(w)
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
for {
select {
case<-r.Context().Done():
returncase<-time.After(1*time.Second):
fmt.Fprintf(w, "data: ping\n\n")
iferr:=rc.Flush(); err!=nil {
return
}
}
}
}package main
import (
"encoding/json""net/http""time""github.com/nissy/bon/v2""github.com/nissy/bon/v2/middleware"
)
typeUserstruct {
IDstring`json:"id"`Namestring`json:"name"`
}
funcmain() {
r:=bon.NewRouter()
// Global middlewarer.Use(middleware.Recovery())
r.Use(middleware.CORS(middleware.AccessControlConfig{
AllowOrigin: "*",
}))
// API routesapi:=r.Group("/api")
api.Use(middleware.Timeout(30*time.Second))
// User routesapi.Get("/users", listUsers)
api.Post("/users", createUser)
api.Get("/users/:id", getUser)
api.Put("/users/:id", updateUser)
api.Delete("/users/:id", deleteUser)
// Nested resourcesapi.Get("/users/:userId/posts", getUserPosts)
api.Post("/users/:userId/posts", createUserPost)
http.ListenAndServe(":8080", r)
}
funcgetUser(w http.ResponseWriter, r*http.Request) {
userID:=bon.URLParam(r, "id")
user:=User{ID: userID, Name: "John Doe"}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}package main
import (
"net/http""time""github.com/nissy/bon/v2""github.com/nissy/bon/v2/middleware"
)
funcmain() {
r:=bon.NewRouter()
// API v1v1:=r.Group("/api/v1")
v1.Use(middleware.CORS(middleware.AccessControlConfig{
AllowOrigin: "*",
}))
v1.Get("/users", v1ListUsers)
v1.Get("/posts", v1ListPosts)
// API v2 with additional featuresv2:=r.Group("/api/v2")
v2.Use(middleware.CORS(middleware.AccessControlConfig{
AllowOrigin: "*",
}))
v2.Use(middleware.Timeout(30*time.Second))
v2.Get("/users", v2ListUsers) // New response formatv2.Get("/posts", v2ListPosts) // Additional fieldsv2.Get("/comments", v2ListComments) // New endpoint// Health check (version independent)r.Get("/health", func(w http.ResponseWriter, r*http.Request) {
w.Write([]byte(`{"status":"ok"}`))
})
http.ListenAndServe(":8080", r)
}package main
import (
"net/http""github.com/nissy/bon/v2""github.com/nissy/bon/v2/middleware"
)
funcmain() {
r:=bon.NewRouter()
// Public endpointsr.Get("/", homeHandler)
r.Get("/login", loginPageHandler)
r.Post("/login", loginHandler)
// Protected APIapi:=r.Group("/api")
api.Use(middleware.BasicAuth([]middleware.BasicAuthUser{
{Name: "user", Password: "pass"},
}))
api.Get("/profile", profileHandler)
api.Get("/settings", settingsHandler)
// Admin area with different authadmin:=r.Group("/admin")
admin.Use(middleware.BasicAuth([]middleware.BasicAuthUser{
{Name: "admin", Password: "admin123"},
}))
admin.Get("/users", listAllUsers)
admin.Delete("/users/:id", deleteUser)
// Webhooks - no auth but standalonewebhooks:=r.Route()
webhooks.Post("/webhook/github", githubWebhook)
webhooks.Post("/webhook/stripe", stripeWebhook)
http.ListenAndServe(":8080", r)
}Run benchmarks locally with:
go test -bench=. ./...For detailed API documentation, see pkg.go.dev/github.com/nissy/bon/v2.
- Route Registration: Static routes are fastest; route priority is static, then parameters, then wildcards
- Middleware Placement: Apply at the appropriate level for best performance
- Static Routes: Use exact paths when possible for fastest matching
- Parameter Reuse: The router pools context objects automatically
- Go 1.23.6 or higher
# Run all tests
go test ./...
# Run tests with race detection
go test -race ./...
# Run benchmarks
go test -bench=. ./...Contributions are welcome! Please feel free to submit a Pull Request.
MIT