Files
go-kite/README.md
Timo Riegebauer ae5d1f610a feat: middleware reach, response writer interfaces, and CORS fixes
- Run global middleware for all requests, including OPTIONS preflights, NotFound, and MethodNotAllowed — previously bypassed by httprouter's internal handling
- Implement Hijacker, Flusher, and Pusher on the response writer for WebSocket, SSE, and HTTP/2 push support
- Fix CORS: echo a single matching origin, handle AllowCredentials with wildcard, append Vary: Origin
- Logger logs from a defer to capture correct status on panicked requests
- Static and StaticFS accept route middleware; add Context.AddHeader; warn on NotFound handler override
2026-05-17 17:51:56 +02:00

18 KiB
Raw Permalink Blame History

go-kite

A fast, lightweight, and expressive HTTP framework for Go, built on top of httprouter.

~132,000 req/sec · sub-4ms latency · ~58MB RAM at peak load

Features

  • Fast — ~132k RPS, sitting just below raw net/http and above Gin, Echo, and Chi
  • Middleware — global, group, and route-level middleware with correct onion ordering
  • Groups — nestable route groups with prefix and middleware inheritance
  • Static files & SPA — serve assets and single-page apps with optional middleware
  • Graceful shutdown — in-flight requests finish cleanly on SIGTERM / SIGINT
  • Configurable — server timeouts, error/not-found/method-not-allowed handlers
  • Rich context — typed request/response helpers, value store, cookie and form support
  • Built-in middleware — logger, recovery, CORS, request ID, max body size
  • WebSocket & SSE ready — response writer implements Hijacker, Flusher, and Pusher

Installation

go get git.trcreatives.at/trcreatives/go-kite

Quickstart

package main

import (
    "net/http"
    "log"

    "git.trcreatives.at/trcreatives/go-kite"
    "git.trcreatives.at/trcreatives/go-kite/middleware"
)

func main() {
    k := kite.New()

    k.Use(
        middleware.Logger(),     // first, so it observes the final status after Recovery
        middleware.Recovery(),
        middleware.RequestID(),
    )

    k.GET("/", func(ctx *kite.Context) error {
        return ctx.WriteJSON(http.StatusOK, map[string]string{
            "message": "Hello, World!",
        })
    })

    if err := k.Start("localhost:8080"); err != nil {
        log.Fatal(err)
    }
}

Routing

k := kite.New()

k.GET("/users", listUsers)
k.POST("/users", createUser)
k.GET("/users/:id", getUser)
k.PUT("/users/:id", updateUser)
k.DELETE("/users/:id", deleteUser)

Supported HTTP methods: GET, POST, PUT, DELETE, HEAD, OPTIONS, CONNECT, TRACE.

Route Groups

Groups allow you to share a common prefix and middleware across multiple routes.

api := k.Group("/api")
api.GET("/users", listUsers)   // GET /api/users
api.POST("/users", createUser) // POST /api/users

Groups can be nested:

api := k.Group("/api")
v1  := api.Group("/v1")
v1.GET("/users", listUsers) // GET /api/v1/users

Middleware can be attached to groups:

api := k.Group("/api", authMiddleware)
admin := api.Group("/admin", adminOnlyMiddleware)
admin.GET("/stats", getStats) // chain: global → auth → adminOnly → handler

Static Files & Single-Page Apps

Static Assets

Static serves files from a directory on disk under a URL prefix:

k.Static("/assets", "./public")
// GET /assets/css/main.css → ./public/css/main.css

Middleware can be applied per-prefix:

k.Static("/admin/assets", "./admin-dist", authMiddleware)

StaticFS is the same but takes an http.FileSystem, useful for embedded assets via embed:

//go:embed dist
var distFS embed.FS

k.StaticFS("/assets", http.FS(distFS))

Both are also available on Group for prefixed/scoped serving.

Single-Page Applications

For Vue, React, Svelte, and similar frameworks, SPA serves the directory and falls back to index.html for unknown paths — letting client-side routing work correctly:

k.SPA("./dist")

SPAFS takes an http.FileSystem for embedded builds.

Note: SPA and SPAFS register the file server as the NotFound handler. If you've already called SetNotFoundHandler (or call it afterward), a warning is logged about the override.

Middleware

Middleware follows the standard onion model — each middleware wraps the next.

type Middleware func(h Handler) Handler

Global Middleware

Applied to every request, including NotFound, MethodNotAllowed, and OPTIONS preflights:

k.Use(middleware.Logger())
k.Use(middleware.Recovery())

Route Middleware

Applied to a single route:

k.GET("/admin", adminHandler, authMiddleware, rateLimitMiddleware)

Execution Order

→ Global 1
  → Global 2
    → Group 1
      → Route 1
        [Handler]
      ← Route 1
    ← Group 1
  ← Global 2
← Global 1

The first middleware registered is the outermost — it runs first on the way in, and last on the way out.

Register Logger before Recovery. The reverse order causes Logger's deferred log line to read the status before Recovery writes the 500, which means panicked requests log as 200.

k.Use(
    middleware.Logger(),     // outermost — sees the final status
    middleware.Recovery(),   // catches panics from inner handlers
    middleware.RequestID(),
)

Writing Custom Middleware

func MyMiddleware(h kite.Handler) kite.Handler {
    return func(ctx *kite.Context) error {
        // before handler
        err := h(ctx)
        // after handler
        return err
    }
}

Built-in Middleware

All middleware lives in the middleware sub-package:

import "git.trcreatives.at/trcreatives/go-kite/middleware"

Logger

Logs method, path, status code and latency for every request. Logs from a defer, so panicked and errored requests are captured too (provided Logger is registered before Recovery).

// simple
k.Use(middleware.Logger())

// custom
k.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
    Output: logFile,
    Format: func(method, path string, statusCode int, latency time.Duration) string {
        return fmt.Sprintf("%s %s %d %dms", method, path, statusCode, latency.Milliseconds())
    },
    Skip: func(ctx *kite.Context) bool {
        return ctx.GetPath() == "/health"
    },
}))

Recovery

Catches panics and returns a 500 instead of crashing the server.

// simple
k.Use(middleware.Recovery())

// custom — integrate with Sentry, Datadog, etc.
k.Use(middleware.RecoveryWithConfig(middleware.RecoveryConfig{
    OnPanic: func(ctx *kite.Context, recovered any, stack []byte) error {
        sentry.CaptureException(fmt.Errorf("%v", recovered))
        return ctx.WriteJSON(http.StatusInternalServerError, map[string]string{
            "error": "internal server error",
        })
    },
}))

RequestID

Attaches a unique ID to every request. Reuses an incoming X-Request-ID header if present, for distributed tracing.

// simple
k.Use(middleware.RequestID())

// custom header
k.Use(middleware.RequestIDWithConfig(middleware.RequestIDConfig{
    Header: "X-Trace-ID",
}))

// retrieve in handler
id := ctx.GetValue("X-Request-ID").(string)

CORS

Sets Cross-Origin Resource Sharing headers and handles preflight requests.

// allow all origins (no credentials)
k.Use(middleware.CORS())

// restrict to specific origins
k.Use(middleware.CORS("https://myapp.com", "https://staging.myapp.com"))

// full control
k.Use(middleware.CORSWithConfig(middleware.CORSConfig{
    AllowedOrigins:   []string{"https://myapp.com"},
    AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE"},
    AllowedHeaders:   []string{"Content-Type", "Authorization"},
    ExposedHeaders:   []string{"X-Request-ID"},
    AllowCredentials: true,
    MaxAge:           86400,
}))

For multi-origin allowlists, the middleware checks the request's Origin against the list and echoes back only the matching one — Access-Control-Allow-Origin always contains a single origin, never a comma-separated list. A Vary: Origin header is added so caches behave correctly.

Note: When AllowCredentials: true is combined with AllowedOrigins: ["*"], the middleware echoes the request's Origin instead of sending *, since browsers reject that combination.

MaxBodySize

Protects against oversized request bodies. Returns 413 if the limit is exceeded.

// global 4MB limit
k.Use(middleware.MaxBodySize(4 << 20))

// per-route override for file uploads
k.POST("/upload", uploadHandler, middleware.MaxBodySize(100 << 20))

Context

Every handler receives a *kite.Context with the following methods:

Request

Method Description
GetRequest() *http.Request Underlying request
GetResponse() http.ResponseWriter Underlying response writer
GetContext() context.Context Underlying context
GetMethod() string HTTP method
GetPath() string Request path
IsMethod(method string) bool Check HTTP method
GetPathParam(key string) string Route parameter (e.g. /users/:id)
GetQueryParam(key string) string Query string parameter
GetHeader(key string) string Request header
GetIP() string Client IP, respects X-Forwarded-For
GetCookie(name string) (*http.Cookie, error) Request cookie
GetStatusCode() int Current response status code

Values

Method Description
SetValue(key string, v any) Store a value in the context
GetValue(key string) any Retrieve a stored value

Forms

Method Description
GetForm() (url.Values, error) All form values
GetFormValue(key string) string Single form value
GetMultipartForm(maxMemory int64) (*multipart.Form, error) Multipart form
GetMultipartFormFile(key string) (multipart.File, *multipart.FileHeader, error) Uploaded file

Body Binding

Method Description
BindJSON(v any) error Decode JSON request body
BindXML(v any) error Decode XML request body

Response

Method Description
WriteBytes(status int, v []byte) error Raw bytes response
WriteString(status int, v string) error Plain text response (UTF-8)
WriteJSON(status int, v any) error JSON response (UTF-8)
WriteXML(status int, v any) error XML response (UTF-8)
WriteNoContent() error 204 No Content
Redirect(status int, url string) error Redirect
SetHeader(key, value string) Set response header (replaces existing)
AddHeader(key, value string) Append response header (for multi-value)
SetCookie(cookie *http.Cookie) Set response cookie

Error Handling

Handlers return error. By default, errors are logged and a 500 is returned to the client.

k.GET("/users/:id", func(ctx *kite.Context) error {
    user, err := db.GetUser(ctx.GetPathParam("id"))
    if err != nil {
        return err // caught by error handler
    }
    return ctx.WriteJSON(http.StatusOK, user)
})

Custom Error Handler

k.SetErrorHandler(func(ctx *kite.Context, err error) error {
    return ctx.WriteJSON(http.StatusInternalServerError, map[string]string{
        "error": err.Error(),
    })
})

Custom Not Found Handler

k.SetNotFoundHandler(func(ctx *kite.Context) error {
    return ctx.WriteJSON(http.StatusNotFound, map[string]string{
        "error": "route not found",
    })
})

Custom Method Not Allowed Handler

k.SetMethodNotAllowedHandler(func(ctx *kite.Context) error {
    return ctx.WriteJSON(http.StatusMethodNotAllowed, map[string]string{
        "error": "method not allowed",
    })
})

Server Configuration

k := kite.New()

k.SetServerReadTimeout(10 * time.Second)     // default: 10s
k.SetServerWriteTimeout(30 * time.Second)    // default: 30s
k.SetServerIdleTimeout(60 * time.Second)     // default: 60s
k.SetServerShutdownTimeout(30 * time.Second) // default: 30s

Graceful Shutdown

Start() blocks until SIGINT or SIGTERM is received, then waits for all in-flight requests to finish before exiting. This maps directly to how Docker, Kubernetes, and systemd stop processes.

if err := k.Start("localhost:8080"); err != nil {
    log.Fatal(err)
}

TLS

Use StartTLS instead of Start and provide your certificate and key files:

if err := k.StartTLS("localhost:443", "cert.pem", "key.pem"); err != nil {
    log.Fatal(err)
}

For local development you can generate a self-signed certificate:

openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes

For production, use a certificate from a trusted CA. Caddy and Nginx are both good options as a reverse proxy if you prefer to terminate TLS outside the application.

Performance

Benchmarked on a MacBook Pro. Each scenario ran for 30 seconds per concurrency level with a 5 second warmup.

Hello World (string + logger middleware)

Concurrency RPS Avg Latency Avg CPU Avg RAM
1 22,076 0.04ms 31.0% 20.95MB
10 79,567 0.12ms 86.2% 24.01MB
25 95,066 0.25ms 88.8% 24.54MB
50 100,283 0.47ms 91.5% 27.81MB
100 113,956 0.83ms 93.5% 30.91MB
250 128,858 1.81ms 97.0% 43.42MB
500 132,760 3.45ms 91.9% 58.20MB
1000 131,101 6.92ms 98.6% 83.23MB
RPS
140k │                                      ▓▓▓▓ ████
120k │                          ▓▓▓▓        ████ ████
100k │              ████  ████  ████  ████  ████ ████
 80k │        ████  ████  ████  ████  ████  ████ ████
 60k │        ████  ████  ████  ████  ████  ████ ████
 40k │  ████  ████  ████  ████  ████  ████  ████ ████
 20k │  ████  ████  ████  ████  ████  ████  ████ ████
     └──────────────────────────────────────────────▶
        1     10    25    50   100   250   500  1000
                        Concurrency

Sweet spot: 250500 workers — RPS plateaus around 132k while latency stays under 4ms.


SQLite Counter (UPDATE + SELECT + JSON response)

Concurrency RPS Avg Latency Avg CPU Avg RAM
1 4,983 0.20ms 19.9% 83.34MB
10 4,728 2.04ms 19.4% 86.30MB
25 4,433 5.50ms 19.0% 88.81MB
50 2,913 15.98ms 29.5% 61.68MB
100 2,139 38.61ms 20.8% 71.38MB
250 628 189.88ms 18.0% 102.66MB
500 257 548.91ms 11.9% 164.40MB
1000 130 853.20ms 9.4% 288.71MB
RPS
5.0k │  ████  ████
4.0k │  ████  ████  ████
3.0k │  ████  ████  ████  ████
2.0k │  ████  ████  ████  ████  ████
1.0k │  ████  ████  ████  ████  ████  ████
 500 │  ████  ████  ████  ████  ████  ████  ████
 100 │  ████  ████  ████  ████  ████  ████  ████  ████
     └──────────────────────────────────────────────▶
        1     10    25    50   100   250   500  1000
                        Concurrency

Sweet spot: 1 worker — SQLite's single-writer model means concurrency hurts. RPS drops sharply beyond 10 workers as write contention builds up. This is a SQLite limitation, not a kite limitation — a Postgres or MySQL backend would scale horizontally.


Key Takeaways

  • Pure HTTP throughput peaks at ~132k RPS with negligible RAM usage (~58MB at peak)
  • Middleware overhead (logger) adds virtually no cost
  • RAM scales linearly with concurrency — roughly 5085 bytes per idle goroutine
  • CPU saturates around 250 workers for in-memory workloads — beyond that, latency grows but RPS flattens
  • Database workloads are bottlenecked by the DB, not the framework — kite's overhead is not the limiting factor

License

MIT