Go http router
Bon is a high-performance HTTP router for Go that uses a double array trie data structure for efficient route matching. It focuses on speed, simplicity, and zero external dependencies.
http.Handler
interface:param
), and wildcard (*
) patterns
package main
import (
"net/http"
"github.com/nissy/bon"
"github.com/nissy/bon/middleware"
)
func main() {
r := bon.NewRouter()
// Global middleware
r.Use(middleware.Recovery()) // Panic recovery
// Simple route
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, Bon!"))
})
// Route with parameter
r.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
Routes are matched in the following priority order (highest to lowest):
Static routes - Exact path match
r.Get("/users/profile", handler) // Highest priority
r.Get("/api/v1/status", handler)
Parameter routes - Named parameter capture
r.Get("/users/:id", handler) // Captures id parameter
r.Get("/posts/:category/:slug", handler)
Wildcard routes - Catch-all pattern
r.Get("/files/*", handler) // Lowest priority
r.Get("/api/*", handler)
// Single parameter
r.Get("/users/:id", func(w http.ResponseWriter, r *http.Request) {
userID := bon.URLParam(r, "id")
// Use userID...
})
// Multiple parameters
r.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 supported
r.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 -> Handler
r.Use(middleware.Recovery()) // 1st - Catches panics
r.Use(middleware.CORS(config)) // 2nd - Handles CORS
api := r.Group("/api")
api.Use(middleware.BasicAuth(users)) // 3rd - Authenticates
api.Get("/data", handler) // Finally, the handler
Catches panics and returns 500 Internal Server Error:
r.Use(middleware.Recovery())
// With custom handler
r.Use(middleware.RecoveryWithHandler(func(w http.ResponseWriter, r *http.Request, err interface{}) {
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 inherit middleware from their parent and prefix all routes:
r := bon.NewRouter()
r.Use(middleware.Recovery()) // Global middleware
// API group inherits Recovery
api := r.Group("/api")
api.Use(middleware.BasicAuth(users)) // Group middleware
// All routes inherit Recovery + BasicAuth
api.Get("/users", listUsers) // GET /api/users
api.Post("/users", createUser) // POST /api/users
// Nested group inherits all parent middleware
v1 := api.Group("/v1")
v1.Get("/posts", listPosts) // GET /api/v1/posts (Recovery + BasicAuth)
Routes are completely independent and don’t inherit any middleware:
r := bon.NewRouter()
r.Use(middleware.BasicAuth(users)) // Global middleware
// This route is NOT affected by global middleware
standalone := r.Route()
standalone.Get("/public", handler) // No auth required
// Must explicitly add middleware if needed
webhook := r.Route()
webhook.Use(webhookMiddleware)
webhook.Post("/webhook", handler) // Only webhook validation, no auth
All 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 handler
r.Handle("CUSTOM", "/", handler)
Serve static files with built-in security:
// Serve files from ./public directory at /static/*
r.FileServer("/static", "./public")
// With middleware
r.FileServer("/assets", "./assets",
middleware.BasicAuth(users),
middleware.CORS(corsConfig),
)
// In a group
admin := r.Group("/admin")
admin.Use(middleware.BasicAuth(adminUsers))
admin.FileServer("/files", "./admin-files")
Security features:
..
, ./
, etc.).
prefix files)
r := bon.NewRouter()
// Method 1: Direct assignment
r.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(404)
w.Write([]byte(`{"error":"not found"}`))
})
// Method 2: Using SetNotFound (respects middleware)
r.SetNotFound(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte("Custom 404 page"))
})
Bon supports 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), you need to access the underlying ResponseWriter through the Unwrap()
method.
package main
import (
"net/http"
"github.com/nissy/bon"
"github.com/nissy/bon/middleware"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // Configure appropriately for production
},
}
func main() {
r := bon.NewRouter()
r.Use(middleware.Recovery())
r.Get("/ws", func(w http.ResponseWriter, r *http.Request) {
// When using middleware that wraps ResponseWriter
var conn *websocket.Conn
var err error
// Try direct upgrade first
conn, err = upgrader.Upgrade(w, r, nil)
if err != nil {
// If failed, try through Unwrap
if unwrapper, ok := w.(interface{ Unwrap() http.ResponseWriter }); ok {
conn, err = upgrader.Upgrade(unwrapper.Unwrap(), r, nil)
}
if err != nil {
http.Error(w, "WebSocket upgrade failed", http.StatusBadRequest)
return
}
}
defer conn.Close()
// Handle WebSocket connection
for {
messageType, p, err := conn.ReadMessage()
if err != nil {
break
}
if err := conn.WriteMessage(messageType, p); err != nil {
break
}
}
})
http.ListenAndServe(":8080", r)
}
package main
import (
"fmt"
"net/http"
"time"
"github.com/nissy/bon"
"github.com/nissy/bon/middleware"
)
func main() {
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 headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
// Get flusher
var flusher http.Flusher
var ok bool
// Try direct cast first
flusher, ok = w.(http.Flusher)
if !ok {
// Try through Unwrap
if unwrapper, 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 events
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
return
case t := <-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"
"github.com/nissy/bon/middleware"
)
func main() {
r := bon.NewRouter()
r.Use(middleware.Recovery())
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
// Get pusher
var pusher http.Pusher
var ok bool
// Try direct cast first
pusher, ok = w.(http.Pusher)
if !ok {
// Try through Unwrap
if unwrapper, ok := w.(interface{ Unwrap() http.ResponseWriter }); ok {
pusher, ok = unwrapper.Unwrap().(http.Pusher)
}
}
// Push resources if available
if pusher != nil {
// Push CSS and JS files
pusher.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 content
w.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 files
r.FileServer("/static", "./static")
// Note: HTTP/2 requires TLS
http.ListenAndServeTLS(":8443", "cert.pem", "key.pem", r)
}
For Go 1.20 and later, you can use http.ResponseController
which automatically handles the Unwrap()
method:
func sseHandler(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():
return
case <-time.After(1 * time.Second):
fmt.Fprintf(w, "data: ping\n\n")
if err := rc.Flush(); err != nil {
return
}
}
}
}
package main
import (
"encoding/json"
"net/http"
"time"
"github.com/nissy/bon"
"github.com/nissy/bon/middleware"
)
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
func main() {
r := bon.NewRouter()
// Global middleware
r.Use(middleware.Recovery())
r.Use(middleware.CORS(middleware.AccessControlConfig{
AllowOrigin: "*",
}))
// API routes
api := r.Group("/api")
api.Use(middleware.Timeout(30 * time.Second))
// User routes
api.Get("/users", listUsers)
api.Post("/users", createUser)
api.Get("/users/:id", getUser)
api.Put("/users/:id", updateUser)
api.Delete("/users/:id", deleteUser)
// Nested resources
api.Get("/users/:userId/posts", getUserPosts)
api.Post("/users/:userId/posts", createUserPost)
http.ListenAndServe(":8080", r)
}
func getUser(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"
"github.com/nissy/bon/middleware"
)
func main() {
r := bon.NewRouter()
// API v1
v1 := r.Group("/api/v1")
v1.Use(middleware.CORS(middleware.AccessControlConfig{
AllowOrigin: "*",
}))
v1.Get("/users", v1ListUsers)
v1.Get("/posts", v1ListPosts)
// API v2 with additional features
v2 := r.Group("/api/v2")
v2.Use(middleware.CORS(middleware.AccessControlConfig{
AllowOrigin: "*",
}))
v2.Use(middleware.Timeout(30 * time.Second))
v2.Get("/users", v2ListUsers) // New response format
v2.Get("/posts", v2ListPosts) // Additional fields
v2.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"
"github.com/nissy/bon/middleware"
)
func main() {
r := bon.NewRouter()
// Public endpoints
r.Get("/", homeHandler)
r.Get("/login", loginPageHandler)
r.Post("/login", loginHandler)
// Protected API
api := 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 auth
admin := 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 standalone
webhooks := r.Route()
webhooks.Post("/webhook/github", githubWebhook)
webhooks.Post("/webhook/stripe", stripeWebhook)
http.ListenAndServe(":8080", r)
}
For detailed API documentation, see pkg.go.dev/github.com/nissy/bon.
# 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